From a08936d72ba640dbb981e2bb462edb742ed66f24 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 11:12:28 +0200 Subject: [PATCH 01/20] feat(restore): add "Restore on click" startup mode Restoring the working set resumes a claude process per session at startup, which costs a lot of memory. The new lazy mode marks last run's sessions in the sidebar (outlined dot, always visible) and resumes each one only when it is opened. Unopened sessions stay in the saved working set across restarts; "Don't restore" drops one without starting it. A reload reattaches only to a PTY that still runs and never spawns. --- .ai/contexts/session-cache.md | 2 + CHANGELOG.md | 1 + docs/session-restore.md | 27 ++ docs/settings.md | 2 +- eslint.config.js | 4 + main.js | 3 + public/app.js | 68 +++- public/grid-view.js | 9 +- public/settings-panel.js | 3 +- public/sidebar.js | 43 ++- public/style.css | 5 + test/continuation-regressions.test.js | 2 +- test/continuation-round4.test.js | 2 +- test/continuation-round5.test.js | 2 +- test/continuations-harness.js | 3 +- test/open-session-once.test.js | 2 +- test/open-session-terminal.test.js | 1 + test/process-exit-status.test.js | 1 + test/restore-lazy.test.js | 449 ++++++++++++++++++++++++++ test/restore-live-elsewhere.test.js | 2 +- test/restore-merged-member.test.js | 6 +- test/restore-pending-persist.test.js | 2 +- test/resume-guard.test.js | 5 +- test/schedule-live-resume.test.js | 2 +- 24 files changed, 615 insertions(+), 31 deletions(-) create mode 100644 test/restore-lazy.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 8042e3d2f..e536fb6b4 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,6 +145,8 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on; `openSession` ignores a call for a session it is already opening (`openingSessions`), so a held key or a double click starts one `openTerminal`. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. + - **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"). - **`session_cache.scheduleSlug` holds nullable schedule provenance.** The runner writes the schedule identity on its pre-seeded user record. Schema reconciliation adds this column without a reindex or cache wipe; runs indexed before the marker stay null unless their transcript supplies it on a later read. A prompt prefix or matching CLI slug does not establish provenance. See `.ai/contexts/schedule-runner.md` for grouping and historical-run limits. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a10bd292..5de117767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### New - **Send to background**, in a running session's header and right-click menu, moves it to the Claude daemon and keeps it open as an attached tab, reattached after each restart. Remote, sandboxed and pre-launch-command sessions cannot move, and a busy session must be idle first; running subagents require confirmation because Claude may stop them. (#546) +- **Restore Sessions on Startup** has a new **Restore on click** option: the sessions open when Switchboard last closed are marked in the sidebar with an outlined dot, and each one is resumed only when you click it, instead of starting a `claude` process for every one at launch. **Don't restore** in a marked session's right-click menu removes it without starting it. (#441) ### Changed - Conversations now show the number of running subagents below their title, even when their child list is collapsed or filtered, replacing the green dots on child rows and collapsed carets. The grid shows the same count in place of coloured pills; no count appears when none are running or activity is unknown. (#627) diff --git a/docs/session-restore.md b/docs/session-restore.md index cd32e3d8b..6040ba179 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -11,6 +11,7 @@ closed. |---|---| | **Don't restore** | Nothing is reopened | | **Ask on startup** (default) | A bar asks *Restore N session(s) from last time?*, with **Restore** and **Dismiss** | +| **Restore on click** | Nothing is started; the sessions are marked in the sidebar, and each is resumed when you click it — see [Restore on click](#restore-on-click) | | **Restore automatically** | The sessions are reopened without asking | The setting is read at launch; a change applies from the next start. @@ -144,5 +145,31 @@ exists. A live background continuation uses the existing attach path and its fin is saved with `bg: true`, so every later restore reattaches it while the job is live. A saved entry without that mark still skips a session live elsewhere. +## Restore on click + +No `claude` process is started at launch. Each saved session that is in the +index gets an outlined dot in the sidebar and stays visible there whatever the +visible-session count and age limit say. The grid starts empty, and no session +is selected, the last active one included. + +- Clicking a marked session resumes it, like any other click. It stays marked + until it is open: if it is live in another process and you decline to resume + it, or its terminal fails to start, it is still marked and still saved. +- **Don't restore**, in a marked session's right-click menu, removes it from + the saved set without starting it. So does deleting the session. +- A marked session never clicked stays in the saved set, so the next start + marks it again. A saved session not yet marked, because indexing has not + reached it, is kept too when the set is saved before then. +- Opening another session does not stop the marking, which goes on until + indexing is over. +- A project folded because its sessions are old opens when it holds a marked + session. +- The previous/next session keys stop on marked sessions too, and resume the + one they land on. The grid has no card for a marked session, so in the grid + they skip it. +- Reloading the window starts nothing. A saved session whose process is still + running is reattached, the selected one included; the others are marked. A + process that ends during the reload is not started again. + Within one run of the app, going back to a session whose process still runs is a reattach: the terminal replays its buffered output onto the same process. diff --git a/docs/settings.md b/docs/settings.md index ec1e003f3..f688338d4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -38,7 +38,7 @@ for one launch — see [Launching sessions](launching-sessions.md). |---|---|---|---| | Terminal Theme | `terminalTheme` | Switchboard | Terminal colours, applied on save — see [Terminal](terminal.md#theme) | | Terminal Right-Click | `terminalRightClick` | Context menu (`menu`) | `menu`, `paste`, `default` (Native), `none` — see [Terminal](terminal.md#right-click) | -| Restore Sessions on Startup | `restoreOnStartup` | Ask on startup (`ask`) | `off`, `ask`, `auto`; read at launch — see [Session restore](session-restore.md) | +| Restore Sessions on Startup | `restoreOnStartup` | Ask on startup (`ask`) | `off`, `ask`, `lazy` (Restore on click), `auto`; read at launch — see [Session restore](session-restore.md) | | Shell Profile | `shellProfile` | Auto (detect) | The shell for sessions and terminals; new sessions only — see [Launching sessions](launching-sessions.md#how-the-command-is-built) | | Max Visible Sessions | `visibleSessionCount` | 10 (1–100) | Sessions shown per project before `+ N older` | | Session Max Age (days) | `sessionMaxAgeDays` | 3 (1–365) | Older sessions go behind `+ N older`; older projects start collapsed | diff --git a/eslint.config.js b/eslint.config.js index c3f243a34..3eb0af2b2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -97,6 +97,10 @@ const rendererCrossFileGlobals = { setActiveSession: 'readonly', activePtyIds: 'writable', pendingSessions: 'readonly', + dormantWorkingSet: 'readonly', + dismissDormantSession: 'readonly', + isDormantSession: 'readonly', + revealDormantSessions: 'readonly', sessionMap: 'writable', lastActivityTime: 'readonly', sortedOrder: 'writable', diff --git a/main.js b/main.js index 86b52b453..a97f2e90e 100644 --- a/main.js +++ b/main.js @@ -2549,6 +2549,9 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se } } + // see .ai/contexts/session-cache.md ("Restore on click") + if (sessionOptions?.reattachOnly) return { ok: false, notLive: true }; + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") if (!isNew) { let cachedFolder = null; diff --git a/public/app.js b/public/app.js index 15336158b..f05298b84 100644 --- a/public/app.js +++ b/public/app.js @@ -168,6 +168,15 @@ let restoreSavedIndex = new Map(); let restoreAwaitingConsent = []; const restoreInFlight = new Map(); +// see .ai/contexts/session-cache.md ("Restore on click") +const dormantWorkingSet = new Map(); + +function dismissDormantSession(sessionId) { + if (!dormantWorkingSet.delete(sessionId)) return; + persistWorkingSet(); + refreshSidebar(); +} + // Serialise concurrent read-modify-write calls so two async persist paths // (e.g. sidebar-resize and a working-set flush arriving in the same tick) // cannot interleave and silently drop each other's keys. @@ -211,8 +220,12 @@ function persistWorkingSet({ final = false } = {}) { .map(entry => ({ ...entry, keepAttached: liveAttach(entry.item.sessionId) })); const heldActive = notStarted.find(({ item }) => item.active); if (heldActive) for (const e of set) e.active = false; - const held = [...skippedWorkingSetEntries.values(), ...notStarted] - .filter(({ item, keepAttached }) => keepAttached ? liveAttach(item.sessionId) || (item.bg && failedBgAttach(item.sessionId)) : !openSessions.has(item.sessionId)) + const held = [...skippedWorkingSetEntries.values(), ...dormantWorkingSet.values(), ...notStarted] + .filter(({ item, keepAttached }) => { + if (keepAttached) return liveAttach(item.sessionId) || (item.bg && failedBgAttach(item.sessionId)); + const open = openSessions.get(item.sessionId); + return !open || open.closed; + }) .sort((a, b) => a.index - b.index); for (const { item, index, keepAttached } of held) { const active = heldActive ? heldActive.item === item : !!keepAttached && item.sessionId === activeSessionId; @@ -368,6 +381,22 @@ async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = } } +// see .ai/contexts/session-cache.md ("Restore on click") +async function reopenActiveSessionAfterReload() { + if (!activeSessionId || openSessions.has(activeSessionId)) return; + const row = sessionMap.get(activeSessionId) || [...sessionMap.values()].find(s => s.mergedMemberIds?.includes(activeSessionId)); + const session = row && (row.sessionId === activeSessionId ? row : { ...row, sessionId: activeSessionId, mergedMemberIds: [] }); + if (!session) return; + const g = await window.api.getSetting('global'); + if (((g && g.restoreOnStartup) || SETTING_DEFAULTS.restoreOnStartup) === 'lazy') { + const live = await window.api.getActiveSessions().catch(() => []); + if (!live.some(e => e.sessionId === session.sessionId)) return; + await openSession(session, undefined, { automatic: true, reattachOnly: true }); + return; + } + await openSession(session, undefined, { automatic: true }); +} + async function restoreWorkingSet() { const g = await window.api.getSetting('global'); restoreMode = (g && g.restoreOnStartup) || SETTING_DEFAULTS.restoreOnStartup; @@ -402,7 +431,7 @@ async function tickRestorePlanner() { sessionMap, openSessions, indexingDone: restoreIndexingDone, - sessionOpenedOutsideRestore, + sessionOpenedOutsideRestore: restoreMode !== 'lazy' && sessionOpenedOutsideRestore, }); if (plan.action === 'wait') { @@ -417,6 +446,21 @@ async function tickRestorePlanner() { const candidates = plan.candidates; + if (restoreMode === 'lazy') { + const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); + const shown = activeSessionId; + for (const [position, item] of candidates.entries()) { + if (live.has(item.sessionId) && sessionMap.has(item.sessionId) + && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true }) !== false) continue; + const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; + dormantWorkingSet.set(item.sessionId, { item, index }); + } + if (shown && activeSessionId !== shown && openSessions.has(shown)) showSession(shown); + refreshSidebar(); + revealDormantSessions(); + return; + } + if (restoreMode === 'auto') { restoringWorkingSet = true; try { @@ -1690,7 +1734,7 @@ function openSession(session, customOptions, flags = {}) { return open.promise; } -async function openSessionNow(session, customOptions, { automatic = false, live, continuationResolved = false, allowBgAttach = false, activate = true, activeFrom, onBlocked } = {}, open = null) { +async function openSessionNow(session, customOptions, { automatic = false, live, continuationResolved = false, allowBgAttach = false, activate = true, activeFrom, onBlocked, reattachOnly = false } = {}, open = null) { if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; if (!automatic) continuationRetryCancelled = true; if (open?.after) await open.after; @@ -1745,8 +1789,9 @@ async function openSessionNow(session, customOptions, { automatic = false, live, const entry = createTerminalEntry(session); // Open terminal in main process — see .ai/contexts/session-state.md ("Reopening a plain terminal") - const resumeOptions = customOptions + let resumeOptions = customOptions || (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath })); + if (reattachOnly) resumeOptions = { ...resumeOptions, reattachOnly: true }; entry.attach = resumeOptions.type === 'attach'; forgetSessionExit(sessionId); beginPtyOpen(sessionId); @@ -1758,6 +1803,10 @@ async function openSessionNow(session, customOptions, { automatic = false, live, throw err; } settlePtyOpen(sessionId, result); + if (result.notLive) { + destroySession(sessionId); + return false; + } if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.openError = result.error; @@ -1774,6 +1823,9 @@ async function openSessionNow(session, customOptions, { automatic = false, live, keepAttached: true, }); } + if (dormantWorkingSet.delete(sessionId)) { + document.getElementById('si-' + sessionId)?.classList.remove('dormant'); + } syncPtySizeAfterOpen(entry, result); if (typeof setSessionMcpState === 'function') setSessionMcpState(sessionId, result.mcpState, result.mcpError); setSessionSandboxed(sessionId, result.sandbox); @@ -2033,11 +2085,7 @@ async function restoreStartupSessions() { window._startupResumeResolutions = new Map(); try { if (localStorage.getItem('gridViewActive') === '1') showGridView(); - if (activeSessionId && !openSessions.has(activeSessionId)) { - const row = sessionMap.get(activeSessionId) || [...sessionMap.values()].find(s => s.mergedMemberIds?.includes(activeSessionId)); - const session = row && (row.sessionId === activeSessionId ? row : { ...row, sessionId: activeSessionId, mergedMemberIds: [] }); - if (session) await openSession(session, undefined, { automatic: true }); - } + await reopenActiveSessionAfterReload(); await restoreWorkingSet(); restoreAgentsViewAtStartup(); } finally { window._startupResumeResolutions = null; } diff --git a/public/grid-view.js b/public/grid-view.js index c13b41b1f..fdf3c2497 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -513,20 +513,21 @@ function toggleGridView() { // --- Session navigation (Cmd+Shift+[/], Cmd+Arrow) --- // Returns ordered list of open (non-closed) session IDs matching sidebar order. -function getOrderedOpenSessionIds() { +function getOrderedOpenSessionIds({ includeDormant = false } = {}) { const items = sidebarContent.querySelectorAll('.session-item[data-session-id]'); const ids = []; for (const item of items) { for (const sid of rowSessionIds({ sessionId: item.dataset.sessionId, mergedMemberIds: item.dataset.mergedMemberIds?.split(' ') })) { const entry = openSessions.get(sid); - if (entry && !entry.closed) ids.push(sid); + if ((entry && !entry.closed) || (includeDormant && isDormantSession(sid))) ids.push(sid); } } return ids; } function navigateSession(direction) { - const ids = getOrderedOpenSessionIds(); + // see .ai/contexts/session-cache.md ("Restore on click") + const ids = getOrderedOpenSessionIds({ includeDormant: !gridViewActive }); const current = gridViewActive ? gridFocusedSessionId : activeSessionId; const idx = ids.indexOf(current); let next; @@ -538,6 +539,8 @@ function navigateSession(direction) { if (ids.length === 0 || !next) return; if (gridViewActive) { focusGridCard(next); + } else if (isDormantSession(next) && sessionMap.has(next)) { + openSession(sessionMap.get(next)); } else { showSession(next); } diff --git a/public/settings-panel.js b/public/settings-panel.js index 53785ebac..5996f54f0 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -250,12 +250,13 @@
Restore Sessions on Startup -
Re-open the sessions that were open when Switchboard last closed. Each is resumed in turn.
+
Re-open the sessions that were open when Switchboard last closed. "On click" only marks them in the sidebar and resumes each one when you click it.
diff --git a/public/sidebar.js b/public/sidebar.js index 56ebefaa8..704bb7f79 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -756,14 +756,15 @@ function renderProjects(projects, resort) { const allItems = []; for (const session of ungrouped) { const isRunning = rowSessionIds(session).some(id => activePtyIds.has(id) || pendingSessions.has(id)); - allItems.push({ sortTime: new Date(session.modified).getTime(), pinned: !!session.starred, running: isRunning, element: buildSessionItem(session) }); + allItems.push({ sortTime: new Date(session.modified).getTime(), pinned: !!session.starred, running: isRunning, dormant: rowSessionIds(session).some(isDormantSession), element: buildSessionItem(session) }); } for (const { slug, scheduled, sessions } of slugMap.values()) { const mostRecentTime = Math.max(...sessions.map(s => new Date(s.modified).getTime())); const hasRunning = sessions.some(s => rowSessionIds(s).some(id => activePtyIds.has(id) || pendingSessions.has(id))); const hasPinned = sessions.some(s => s.starred); + const hasDormant = sessions.some(s => isDormantSession(s.sessionId)); const element = sessions.length === 1 && !scheduled ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions, subagentIndex, scheduled, project, legacyScheduleScopes); - allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, element }); + allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, dormant: hasDormant, element }); } // Sort render items @@ -796,7 +797,7 @@ function renderProjects(projects, resort) { let count = 0; const ageCutoff = Date.now() - sessionMaxAgeDays * 86400000; for (const item of allItems) { - if (item.running || item.pinned || (count < visibleSessionCount && item.sortTime >= ageCutoff)) { + if (item.running || item.pinned || item.dormant || (count < visibleSessionCount && item.sortTime >= ageCutoff)) { visible.push(item); count++; } else { @@ -1066,6 +1067,8 @@ function renderProjects(projects, resort) { if (activeItem) activeItem.classList.add('active'); } + revealDormantSessions(newSidebar); + morphdom(sidebarContent, newSidebar, { childrenOnly: true, onBeforeElUpdated(fromEl, toEl) { @@ -1655,6 +1658,7 @@ function rebindSidebarEvents(projects) { // The sidebar re-injects transcript-less sessions from pendingSessions on // every load, so forget it here or a deleted placeholder card comes back. if (typeof pendingSessions !== 'undefined') pendingSessions.delete(session.sessionId); + if (typeof dismissDormantSession === 'function') dismissDormantSession(session.sessionId); if (typeof sessionMap !== 'undefined') sessionMap.delete(session.sessionId); // Close the tab too — otherwise it stays open pointing at a transcript // that no longer exists for the rest of this run. @@ -1755,6 +1759,18 @@ function showSessionContextMenu(event, sessionId) { sendSessionToBackground(openId); }; menu.appendChild(bgButton); + if (isDormantSession(sessionId)) { + const dismiss = document.createElement('button'); + dismiss.className = 'popover-option session-dismiss-dormant-btn'; + dismiss.setAttribute('role', 'menuitem'); + dismiss.textContent = "Don't restore"; + dismiss.title = 'Remove this session from the sessions to restore, without starting it'; + dismiss.onclick = () => { + closeSessionContextMenu(); + dismissDormantSession(sessionId); + }; + menu.appendChild(dismiss); + } document.body.appendChild(menu); menu.style.left = Math.max(0, Math.min(event.clientX, window.innerWidth - menu.offsetWidth - SESSION_MENU_EDGE_MARGIN)) + 'px'; menu.style.top = Math.max(0, Math.min(event.clientY, window.innerHeight - menu.offsetHeight - SESSION_MENU_EDGE_MARGIN)) + 'px'; @@ -1773,6 +1789,26 @@ function remoteAttentionSnapshot(sessionId) { return !snapshot.attached && snapshot.attention ? snapshot : null; } +// see .ai/contexts/session-cache.md ("Restore on click") +function revealDormantSessions(root = sidebarContent) { + for (const row of root.querySelectorAll('.session-item.dormant')) { + let el = row; + while (el && el !== root) { + if (el.classList.contains('slug-group')) el.classList.remove('collapsed'); + const header = el.previousElementSibling; + if ((el.classList.contains('project-sessions') || el.classList.contains('worktree-sessions')) + && header && header.classList.contains('collapsed')) { + header.classList.remove('collapsed'); + } + el = el.parentElement; + } + } +} + +function isDormantSession(sessionId) { + return typeof dormantWorkingSet !== 'undefined' && dormantWorkingSet.has(sessionId); +} + function buildSessionItem(session) { const item = document.createElement('div'); item.className = 'session-item js-stateful'; @@ -1782,6 +1818,7 @@ function buildSessionItem(session) { if (rowSessionIds(session).some(id => activePtyIds.has(id))) item.classList.add('has-running-pty'); const ids = rowSessionIds(session); item.dataset.headSessionId = rowHeadId(session); + if (ids.some(isDormantSession)) item.classList.add('dormant'); if (session.mergedMemberIds?.length) item.dataset.mergedMemberIds = session.mergedMemberIds.join(' '); const snapshot = mergedRowSnapshot(ids, session.sessionId, storedActivitySnapshot(session.sessionId, session), session); diff --git a/public/style.css b/public/style.css index 12011007c..41f1950ac 100644 --- a/public/style.css +++ b/public/style.css @@ -1071,6 +1071,11 @@ body { display: flex; flex-direction: column; } background: #3ecf5a; } +.session-item.dormant .session-icon { + background: transparent; + box-shadow: inset 0 0 0 1px #3ecf5a; +} + /* ---- Icon slot rungs ---- keyed on session-icon-- alone, no :not() chains — see .ai/contexts/session-state.md */ .session-icon--idle {} diff --git a/test/continuation-regressions.test.js b/test/continuation-regressions.test.js index b5b13a61d..6369f7277 100644 --- a/test/continuation-regressions.test.js +++ b/test/continuation-regressions.test.js @@ -192,7 +192,7 @@ test('R2 startup resolves an active continued session only once', { timeout: 900 const h = setup(t, { old: ['new'], new: [] }); const lookup = t.mock.method(h.ctx.api, 'getSessionContinuations'); vm.runInContext("activeSessionId = 'old';", h.ctx); - loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'loadStartupProjects'] }); + loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'loadStartupProjects'] }); h.ctx.restoreWorkingSet = h.restore; h.ctx.restoreAgentsViewAtStartup = () => {}; h.ctx.loadProjects = async () => {}; diff --git a/test/continuation-round4.test.js b/test/continuation-round4.test.js index d30b51b18..4220879fc 100644 --- a/test/continuation-round4.test.js +++ b/test/continuation-round4.test.js @@ -160,7 +160,7 @@ test('R4 R2 remembered automatic open does not cancel the indexing retry', { tim vm.runInContext('restoringWorkingSet = false;', h.ctx); }; h.ctx.restoreAgentsViewAtStartup = () => {}; - const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions'] }); + const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload'] }); await startup.restoreStartupSessions(); await app.markRestoreIndexingDone(); assert.deepEqual(h.spawned.map(call => call.id), ['new']); diff --git a/test/continuation-round5.test.js b/test/continuation-round5.test.js index 59a1d9163..5da20c193 100644 --- a/test/continuation-round5.test.js +++ b/test/continuation-round5.test.js @@ -50,7 +50,7 @@ for (const mode of ['ask', 'auto']) { vm.runInContext('restoringWorkingSet = false; activeSessionId = "old";', h.ctx); const app = loadAppFunctions(h.ctx, { declarations: ['restoreMode'], - functions: ['restoreStartupSessions', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice'], + functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice'], }); await app.restoreStartupSessions(); assert.equal(h.dom.window.document.getElementById('restore-toast'), null, 'reload must not ask to restore saved sessions'); diff --git a/test/continuations-harness.js b/test/continuations-harness.js index 1fb8372ab..8a221687a 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -75,8 +75,9 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } loadAppFunctions(ctx, { functions: ['rowSessionIds', 'rowHeadId', 'rowOpenId'], sourcePath: path.join(__dirname, '../public/session-activity-dom.js'), }); + dom.window.SETTING_DEFAULTS = require('../public/setting-defaults').SETTING_DEFAULTS; const app = loadAppFunctions(ctx, { - declarations: ['openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting'], + declarations: ['openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], functions: ['runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice'], }); return { diff --git a/test/open-session-once.test.js b/test/open-session-once.test.js index 36c304bee..6f948e97e 100644 --- a/test/open-session-once.test.js +++ b/test/open-session-once.test.js @@ -58,7 +58,7 @@ function setup() { window.sessionMap.set('s1', { sessionId: 's1', projectPath: PROJECT }); window.sessionMap.set('s2', { sessionId: 's2', projectPath: PROJECT }); loadAppFunctions(ctx.context, { - declarations: ['continuationRetryCancelled', 'openingSessions'], + declarations: ['continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet'], functions: ['openSession', 'openSessionNow'], }); window.renderProjects(window.cachedProjects, true); diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index 619c9a6d6..019130f3e 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -24,6 +24,7 @@ async function withHarness(setup, fn) { sessionOpenedOutsideRestore: false, openSessions: new Map(), skippedWorkingSetEntries: new Set(), + dormantWorkingSet: new Map(), destroySession: (id) => calls.destroyed.push(id), showSession: (id) => calls.shown.push(id), guardResume: async () => true, diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 35656c14d..e6fdeeaaf 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -49,6 +49,7 @@ const PRELUDE = ` const sessionMap = new Map(); const pendingSessions = new Map(); const skippedWorkingSetEntries = new Set(); + const dormantWorkingSet = new Map(); const cachedProjects = []; const cachedAllProjects = []; const gridCards = new Map(); diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js new file mode 100644 index 000000000..6eef75eb8 --- /dev/null +++ b/test/restore-lazy.test.js @@ -0,0 +1,449 @@ +// see .ai/contexts/session-cache.md ("Restore on click") + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const { setupSidebarDom } = require('./dom-setup'); +const { loadAppFunctions, extractFunction } = require('./app-source'); +const { SETTING_DEFAULTS } = require('../public/setting-defaults'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); +const PROJECT = '/home/dev/proj'; +const OLD = '2020-01-01T00:00:00Z'; +const SAVED = [ + { sessionId: 'a', projectPath: PROJECT, active: false }, + { sessionId: 'x', projectPath: PROJECT, active: true }, + { sessionId: 'b', projectPath: PROJECT, active: false }, +]; + +function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, openResult = { ok: true }, mode = 'lazy', livePtys = [], recent = true } = {}) { + const ctx = setupSidebarDom(); + const { window, context } = ctx; + const run = (code) => vm.runInContext(code, context); + for (const f of ['resume-guard.js', 'restore-plan.js']) { + vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), context, { filename: f }); + } + + const settings = { global: { restoreOnStartup: mode, openWorkingSet: savedSet } }; + const openTerminalCalls = []; + const openTerminalOptions = []; + const live = { ptys: livePtys }; + const confirms = []; + const gridFocused = []; + Object.assign(window.api, { + getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), + setSetting: async (key, value) => { settings[key] = JSON.parse(JSON.stringify(value)); }, + getSessionLiveElsewhere: async (id) => liveElsewhere[id] || null, + getSessionsLiveElsewhere: async () => ({}), + getActiveSessions: async () => live.ptys.map(sessionId => ({ sessionId, busy: false })), + deleteSession: async () => ({ ok: true }), + openTerminal: async (sessionId, _projectPath, _isNew, options) => { + openTerminalCalls.push(sessionId); + openTerminalOptions.push(options); + return typeof openResult === 'function' ? openResult(sessionId, options) : openResult; + }, + }); + window.confirm = (msg) => { confirms.push(msg); return confirmAnswer; }; + + const project = { + projectPath: PROJECT, + sessions: [ + ...(recent ? ['n1', 'n2', 'n3'] : []).map((id, i) => ({ sessionId: id, name: id, modified: new Date(Date.now() - i * 1000).toISOString(), messageCount: 1 })), + ...savedSet.map(({ sessionId }) => ({ sessionId, name: sessionId, modified: OLD, messageCount: 1 })), + ], + }; + window.cachedProjects = [project]; + window.cachedAllProjects = [project]; + window.visibleSessionCount = 2; + window.sessionMaxAgeDays = 30; + let sidebarRefreshes = 0; + Object.assign(window, { + refreshSidebar: () => { sidebarRefreshes++; window.renderProjects(window.cachedProjects, true); }, + createTerminalEntry: (session) => { + const entry = { session, closed: false, terminal: { write() {}, focus() {} }, initialSize: {} }; + window.openSessions.set(session.sessionId, entry); + return entry; + }, + showSession: (id) => { run(`activeSessionId = ${JSON.stringify(id)}`); }, + destroySession: (id) => { window.openSessions.delete(id); }, + resolveDefaultSessionOptions: async () => ({}), + resolveResumeSession: async (session) => session, + syncPtySizeAfterOpen: () => {}, + setSessionMcpState: () => {}, + setSessionSandboxed: () => {}, + forgetSessionExit: () => {}, + beginPtyOpen: () => {}, + settlePtyOpen: () => {}, + schedulePersistWorkingSet: () => {}, + pollActiveSessions: () => {}, + showColdCacheNotice: () => {}, + showNotRestoredNotice: () => {}, + runRestore: async () => { throw new Error('lazy restore must not resume anything'); }, + showDeleteSessionDialog: async () => true, + stopBeforeArchive: async () => ({ ok: true }), + loadProjects: () => {}, + focusGridCard: (id) => { gridFocused.push(id); }, + SETTING_DEFAULTS, + }); + + const fns = loadAppFunctions(context, { + declarations: ['restoringWorkingSet', 'restorePlanner', 'restoreMode', 'restoreIndexingDone', + 'sessionOpenedOutsideRestore', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', + 'dormantWorkingSet', '_persistChain', 'openingSessions', 'continuationRetryCancelled'], + functions: ['dismissDormantSession', 'persistWorkingSet', 'pendingRestoreEntries', 'tickRestorePlanner', 'openSession', 'openSessionNow', + 'reopenActiveSessionAfterReload'], + }); + const gridSrc = fs.readFileSync(path.join(PUBLIC_DIR, 'grid-view.js'), 'utf8'); + vm.runInContext(['var gridViewActive = false;', 'var gridFocusedSessionId = null;', + extractFunction(gridSrc, 'getOrderedOpenSessionIds'), extractFunction(gridSrc, 'navigateSession')].join('\n'), context); + context.savedSet = savedSet; + run(` + restoreMode = 'lazy'; + restoreSavedIndex = new Map(savedSet.map((item, index) => [item.sessionId, index])); + restorePlanner = createRestorePlanner({ savedSet }); + `); + + return { + window, settings, openTerminalCalls, openTerminalOptions, confirms, gridFocused, fns, run, + index: (ids) => { + for (const id of ids) { + const item = savedSet.find((i) => i.sessionId === id); + window.sessionMap.set(id, { sessionId: id, projectPath: item.projectPath }); + } + }, + sidebarRefreshes: () => sidebarRefreshes, + setLivePtys: (ids) => { live.ptys = ids; }, + open: (id) => fns.openSession(window.sessionMap.get(id)), + dormantIds: () => [...run('[...dormantWorkingSet.keys()]')].sort(), + savedIds: () => settings.global.openWorkingSet.map((i) => i.sessionId), + row: (id) => window.document.getElementById('si-' + id), + destroy: () => ctx.destroy(), + }; +} + +test('lazy: the saved sessions are marked dormant and none is resumed', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, []); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + assert.equal(h.sidebarRefreshes(), 1); + } finally { h.destroy(); } +}); + +test('lazy: dormant sessions stay in the persisted working set at their saved place', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + assert.ok(h.settings.global.openWorkingSet.every((i) => i.active === false)); + } finally { h.destroy(); } +}); + +test('lazy: opening a dormant session resumes only that one and keeps the others', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + assert.deepEqual(h.openTerminalCalls, ['x']); + assert.deepEqual(h.dormantIds(), ['a', 'b']); + assert.equal(h.row('x').classList.contains('dormant'), false, 'the row loses its mark without waiting for a render'); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + assert.equal(h.settings.global.openWorkingSet.find((i) => i.sessionId === 'x').active, true); + } finally { h.destroy(); } +}); + +test('lazy: a dormant session live elsewhere stays dormant and saved when the user refuses to resume it', async () => { + const h = setup({ liveElsewhere: { x: { pid: 4242 } }, confirmAnswer: false }); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + assert.equal(h.confirms.length, 1, 'the user is asked before resuming a session live elsewhere'); + assert.deepEqual(h.openTerminalCalls, []); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + assert.ok(h.row('x').classList.contains('dormant')); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + } finally { h.destroy(); } +}); + +test('lazy: a dormant session whose terminal fails to open stays dormant and saved', async () => { + const h = setup({ openResult: { ok: false, error: 'spawn failed' } }); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + assert.deepEqual(h.openTerminalCalls, ['x']); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + } finally { h.destroy(); } +}); + +test('lazy: a session opened during a cold index does not stop the rest from being marked', async () => { + const h = setup(); + try { + h.index(['a']); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['a']); + await h.open('a'); + h.index(['x', 'b']); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['b', 'x']); + assert.deepEqual(h.openTerminalCalls, ['a']); + } finally { h.destroy(); } +}); + +test('lazy sidebar: dormant rows are marked and stay visible past the visible count and the age limit', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + for (const id of ['a', 'x', 'b']) { + const row = h.row(id); + assert.ok(row, `${id} is rendered`); + assert.ok(row.classList.contains('dormant'), `${id} is marked dormant`); + assert.equal(row.closest('.sessions-older'), null, `${id} is not folded under "older"`); + } + assert.ok(h.row('n3').closest('.sessions-older'), 'a non-dormant row past the visible count is folded'); + } finally { h.destroy(); } +}); + +test('lazy sidebar: "Don\'t restore" drops a dormant session from the working set without starting it', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + const { document } = h.window; + h.window.sessionMap.set('n1', { sessionId: 'n1', projectPath: PROJECT }); + h.window.refreshSidebar(); + h.row('n1').dispatchEvent(new h.window.MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 5 })); + assert.ok(document.querySelector('.session-context-menu'), 'the menu of a row that is not dormant opens'); + assert.equal(document.querySelector('.session-dismiss-dormant-btn'), null, 'and has no "Don\'t restore" item'); + h.window.closeSessionContextMenu(); + + h.row('x').dispatchEvent(new h.window.MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 5 })); + const dismiss = document.querySelector('.session-dismiss-dormant-btn'); + assert.ok(dismiss, 'the menu of a dormant row offers "Don\'t restore"'); + dismiss.click(); + await h.window.eval('_persistChain'); + assert.deepEqual(h.openTerminalCalls, []); + assert.deepEqual(h.dormantIds(), ['a', 'b']); + assert.deepEqual(h.savedIds(), ['a', 'b']); + assert.equal(h.row('x').classList.contains('dormant'), false); + } finally { h.destroy(); } +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('lazy: saved sessions not indexed yet stay in the saved set when it is persisted during the cold index', async () => { + const h = setup(); + try { + h.index(['a']); + await h.fns.tickRestorePlanner(); + h.window.sessionMap.set('n1', { sessionId: 'n1', projectPath: PROJECT }); + await h.open('n1'); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b', 'n1'], 'x and b, still unindexed, are kept'); + + h.index(['x', 'b']); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b', 'n1']); + } finally { h.destroy(); } +}); + +test('lazy: a renderer reload reattaches the remembered session only while its PTY still runs', async () => { + for (const { mode, livePtys, expected } of [ + { mode: 'lazy', livePtys: [], expected: [] }, + { mode: 'lazy', livePtys: ['x'], expected: ['x'] }, + { mode: 'auto', livePtys: [], expected: ['x'] }, + ]) { + const h = setup({ mode, livePtys }); + try { + h.index(['a', 'x', 'b']); + h.run('activeSessionId = "x"'); + await h.fns.reopenActiveSessionAfterReload(); + assert.deepEqual(h.openTerminalCalls, expected, `${mode}, live PTYs ${JSON.stringify(livePtys)}`); + } finally { h.destroy(); } + } +}); + +test('lazy sidebar: a project collapsed for its age opens to show its dormant rows', async () => { + const h = setup({ recent: false }); + try { + h.index(['a', 'x', 'b']); + h.window.renderProjects(h.window.cachedProjects, true); + const header = () => h.row('x').closest('.project-sessions').previousElementSibling; + assert.ok(header().classList.contains('collapsed'), 'collapsed for its age before the restore'); + await h.fns.tickRestorePlanner(); + assert.equal(header().classList.contains('collapsed'), false); + h.window.refreshSidebar(); + assert.equal(header().classList.contains('collapsed'), false, 'and stays open on the next render'); + } finally { h.destroy(); } +}); + +test('lazy sidebar: deleting a dormant session drops it from the saved set', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + h.row('x').querySelector('.session-delete-btn').click(); + await settle(); + await h.window.eval('_persistChain'); + assert.deepEqual(h.dormantIds(), ['a', 'b']); + assert.deepEqual(h.savedIds(), ['a', 'b']); + assert.deepEqual(h.openTerminalCalls, []); + } finally { h.destroy(); } +}); + +test('lazy: keyboard navigation reaches dormant rows and opens them, except in the grid', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + for (let i = 0; i < 2; i++) { + h.run('navigateSession(1)'); + for (let j = 0; j < 5; j++) await settle(); + } + assert.deepEqual([...h.openTerminalCalls].sort(), ['a', 'b', 'x']); + assert.deepEqual(h.dormantIds(), []); + + const g = setup(); + try { + g.index(['a', 'x', 'b']); + await g.fns.tickRestorePlanner(); + await g.open('x'); + g.run('gridViewActive = true; gridFocusedSessionId = "x"'); + g.run('navigateSession(1)'); + await settle(); + assert.deepEqual(g.openTerminalCalls, ['x']); + assert.deepEqual(g.gridFocused, ['x'], 'the grid has no card for a dormant session'); + } finally { g.destroy(); } + } finally { h.destroy(); } +}); + +test('lazy: a reload whose PTY exits before the reattach asks main for a reattach only, and leaves the session dormant', async () => { + const h = setup({ livePtys: ['x'], openResult: { ok: false, notLive: true } }); + try { + h.index(['a', 'x', 'b']); + h.run('activeSessionId = "x"'); + await h.fns.reopenActiveSessionAfterReload(); + assert.deepEqual(h.openTerminalCalls, ['x']); + assert.equal(h.openTerminalOptions[0].reattachOnly, true, 'main is told not to spawn'); + assert.equal(h.window.openSessions.has('x'), false, 'no terminal is left behind'); + h.setLivePtys([]); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + } finally { h.destroy(); } +}); + +test('main refuses to spawn for a reattach-only open when no PTY is live', () => { + const MAIN = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + const start = MAIN.indexOf("ipcMain.handle('open-terminal'"); + const reattach = MAIN.indexOf('ok: true, reattached: true', start); + const refuse = MAIN.indexOf('if (sessionOptions?.reattachOnly) return { ok: false, notLive: true };', start); + const spawn = MAIN.indexOf('// Spawn new PTY', start); + assert.ok(start > 0 && reattach > start && refuse > reattach && spawn > refuse, + 'the refusal sits after the reattach of a live PTY and before anything is started'); + const remote = MAIN.indexOf('remoteAttachAdapter.attach(', start); + assert.ok(remote > refuse, 'and before a remote attach'); +}); + +test('lazy: a session whose PTY still runs after a reload is reattached, not marked dormant', async () => { + const h = setup({ livePtys: ['a'] }); + try { + h.index(['a', 'x', 'b']); + h.run('activeSessionId = "x"'); + h.window.createTerminalEntry({ sessionId: 'x', projectPath: PROJECT }); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['a']); + assert.equal(h.openTerminalOptions[0].reattachOnly, true); + assert.deepEqual(h.dormantIds(), ['b']); + assert.equal(h.run('activeSessionId'), 'x', 'the selected session stays selected'); + } finally { h.destroy(); } +}); + +test('lazy: a running session whose PTY exits before the reattach is marked dormant', async () => { + const h = setup({ livePtys: ['a'], openResult: { ok: false, notLive: true } }); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['a']); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + assert.equal(h.window.openSessions.has('a'), false); + } finally { h.destroy(); } +}); + +test('lazy: a held or repeated next-session key opens a dormant session once', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + h.run('navigateSession(1); navigateSession(1);'); + for (let j = 0; j < 5; j++) await settle(); + assert.equal(h.openTerminalCalls.length, 2, JSON.stringify(h.openTerminalCalls)); + assert.equal(new Set(h.openTerminalCalls).size, 2); + } finally { h.destroy(); } +}); + +const WORKTREE = PROJECT + '/.claude/worktrees/wt1'; + +function setupGroups({ recent = true } = {}) { + const savedSet = [...SAVED, { sessionId: 'w', projectPath: WORKTREE, active: false }]; + const h = setup({ savedSet, recent }); + const project = h.window.cachedProjects[0]; + project.sessions = project.sessions.filter((s) => s.sessionId !== 'w'); + for (const s of project.sessions) if (s.sessionId === 'x') s.slug = 'sl'; + project.sessions.push({ sessionId: 'x2', name: 'x2', slug: 'sl', modified: OLD, messageCount: 1 }); + const worktree = { projectPath: WORKTREE, sessions: [{ sessionId: 'w', name: 'w', modified: OLD, messageCount: 1 }] }; + h.window.cachedProjects = [project, worktree]; + h.window.cachedAllProjects = [project, worktree]; + h.window.renderProjects(h.window.cachedProjects, true); + return h; +} + +test('lazy sidebar: a dormant session in a slug group or a worktree is shown, past the visible count and the age limit', async () => { + const h = setupGroups(); + try { + const slugGroup = () => h.row('x').closest('.slug-group'); + const wtHeader = () => h.row('w').closest('.worktree-sessions').previousElementSibling; + assert.ok(slugGroup().classList.contains('collapsed'), 'the slug group starts folded'); + assert.ok(wtHeader().classList.contains('collapsed'), 'the worktree is collapsed for its age'); + h.index(['a', 'x', 'b', 'w']); + await h.fns.tickRestorePlanner(); + assert.equal(slugGroup().classList.contains('collapsed'), false); + assert.equal(slugGroup().closest('.sessions-older'), null, 'the slug group is not folded under "older"'); + assert.equal(wtHeader().classList.contains('collapsed'), false); + } finally { h.destroy(); } +}); + +test('lazy sidebar: a group built again after the marking shows its dormant rows', async () => { + const h = setupGroups({ recent: false }); + try { + h.index(['a', 'x', 'b', 'w']); + await h.fns.tickRestorePlanner(); + const projects = h.window.cachedProjects; + h.window.renderProjects([], true); + assert.equal(h.row('x'), null); + h.window.renderProjects(projects, true); + const header = h.row('a').closest('.project-sessions').previousElementSibling; + assert.equal(header.classList.contains('collapsed'), false, 'project'); + assert.equal(h.row('x').closest('.slug-group').classList.contains('collapsed'), false, 'slug group'); + assert.equal(h.row('w').closest('.worktree-sessions').previousElementSibling.classList.contains('collapsed'), false, 'worktree'); + } finally { h.destroy(); } +}); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index f5741c689..e782c3fa5 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -96,7 +96,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { loadAppFunctions(ctx, { functions: ['rowSessionIds', 'rowHeadId', 'rowOpenId'], sourcePath: path.join(__dirname, '../public/session-activity-dom.js'), }); - vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'openingSessions']) + vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet']) .replace(/^const /gm, 'var ').replace(/^let /gm, 'var '), ctx); for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession', 'openSessionNow']) { vm.runInContext(functionSource(APP_SRC, name), ctx); diff --git a/test/restore-merged-member.test.js b/test/restore-merged-member.test.js index 64f9d4c42..e7b488967 100644 --- a/test/restore-merged-member.test.js +++ b/test/restore-merged-member.test.js @@ -57,13 +57,13 @@ function setup(t, { id = 'B', ids = [id], members = ['B'], headSessionId, mode = 'restoringWorkingSet', 'restorePlanner', 'restoreMode', 'restoreIndexingDone', 'sessionOpenedOutsideRestore', 'continuationRetryCancelled', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', '_persistChain', - 'exitingApp', 'savedWorkingSetRead', 'RESTORE_STAGGER_MS', 'LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', + 'exitingApp', 'savedWorkingSetRead', 'RESTORE_STAGGER_MS', 'LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'dormantWorkingSet', ], functions: [ 'dedup', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showRestoreNotice', 'showNotRestoredNotice', 'showLiveElsewhereNotice', 'showColdCacheNotice', - 'restoreStartupSessions', + 'restoreStartupSessions', 'reopenActiveSessionAfterReload', ], }); Object.assign(h.window, { @@ -217,7 +217,7 @@ test('a remembered active id absent from all rows only restores the working set' restoreWorkingSet: async () => { calls.push('working set'); }, restoreAgentsViewAtStartup() {}, }); - const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions'] }); + const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload'] }); await assert.doesNotReject(restoreStartupSessions); assert.deepEqual(calls, ['working set']); assert.equal(ctx.window._startupResumeResolutions, null); diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index 735fcac9c..007a1005c 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -47,7 +47,7 @@ function setup() { `, ctx); const fns = loadAppFunctions(ctx, { functions: ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore'], - declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'exitingApp', 'persistSkippedWhileExiting'], + declarations: ['skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], }); vm.runInContext(`restoreSavedIndex = new Map(${JSON.stringify(SAVED.map((item, i) => [item.sessionId, i]))});`, ctx); const stored = () => settings.global.openWorkingSet.map(i => i.sessionId); diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index 6dd35e7aa..06cb9b8a4 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -154,7 +154,8 @@ test('openSession runs the guard before it asks main for a PTY', () => { test('the reload path resumes the remembered session as an automatic resume', async () => { const calls = []; const ctx = vm.createContext({ - window: {}, + window: { api: { getSetting: async () => null } }, + SETTING_DEFAULTS: { restoreOnStartup: 'ask' }, localStorage: { getItem: () => null }, activeSessionId: SESSION.sessionId, openSessions: new Map(), @@ -165,7 +166,7 @@ test('the reload path resumes the remembered session as an automatic resume', as restoreWorkingSet: async () => { calls.push('working set'); }, restoreAgentsViewAtStartup() {}, }); - const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions'] }); + const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload'] }); await restoreStartupSessions(); assert.deepEqual(calls, [ { session: SESSION, options: undefined, automatic: true }, diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index dbed99c84..1304a6e49 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -108,7 +108,7 @@ function setup(t, { sandbox = false } = {}) { }); const app = loadAppFunctions(ctx, { functions: ['openSession', 'openSessionNow', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice'], - declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting'], + declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], }); ctx.sessionMap.set(SID, session); t.after(() => { From cb2ad3ab08d6a3b7aefc62b14de85441524c5c41 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 11:20:09 +0200 Subject: [PATCH 02/20] fix(restore): no dormant resume on a key repeat, and hold candidates while reattaching A held previous/next key resumed one dormant session after another: an auto-repeat now cycles the open sessions only. The lazy branch keeps its candidates in restoreInFlight while it awaits the reattaches, so a persist in that window keeps them, and a failed open from the keyboard is logged instead of left as an unhandled rejection. The changelog entry moves under Unreleased and names the keys. --- .ai/contexts/session-cache.md | 2 +- CHANGELOG.md | 2 +- docs/session-restore.md | 4 ++- public/app.js | 19 ++++++++---- public/grid-view.js | 10 +++---- test/restore-lazy.test.js | 55 +++++++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+), 14 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index e536fb6b4..d0b0afe45 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on; `openSession` ignores a call for a session it is already opening (`openingSessions`), so a held key or a double click starts one `openTerminal`. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de117767..e5f6f7a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### New - **Send to background**, in a running session's header and right-click menu, moves it to the Claude daemon and keeps it open as an attached tab, reattached after each restart. Remote, sandboxed and pre-launch-command sessions cannot move, and a busy session must be idle first; running subagents require confirmation because Claude may stop them. (#546) -- **Restore Sessions on Startup** has a new **Restore on click** option: the sessions open when Switchboard last closed are marked in the sidebar with an outlined dot, and each one is resumed only when you click it, instead of starting a `claude` process for every one at launch. **Don't restore** in a marked session's right-click menu removes it without starting it. (#441) +- **Restore Sessions on Startup** has a new **Restore on click** option: the sessions open when Switchboard last closed are marked in the sidebar with an outlined dot, and each one is resumed only when you open it, by a click or with the previous/next session keys, instead of starting a `claude` process for every one at launch. **Don't restore** in a marked session's right-click menu removes it without starting it. (#441) ### Changed - Conversations now show the number of running subagents below their title, even when their child list is collapsed or filtered, replacing the green dots on child rows and collapsed carets. The grid shows the same count in place of coloured pills; no count appears when none are running or activity is unknown. (#627) diff --git a/docs/session-restore.md b/docs/session-restore.md index 6040ba179..7be8d231a 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -165,7 +165,9 @@ is selected, the last active one included. - A project folded because its sessions are old opens when it holds a marked session. - The previous/next session keys stop on marked sessions too, and resume the - one they land on. The grid has no card for a marked session, so in the grid + one they land on. Holding a key down skips them: the repeats cycle through + the open sessions only, so they never resume a row of marked sessions one + after another. The grid has no card for a marked session, so in the grid they skip it. - Reloading the window starts nothing. A saved session whose process is still running is reattached, the selected one included; the others are marked. A diff --git a/public/app.js b/public/app.js index f05298b84..5c865cd66 100644 --- a/public/app.js +++ b/public/app.js @@ -447,13 +447,20 @@ async function tickRestorePlanner() { const candidates = plan.candidates; if (restoreMode === 'lazy') { - const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); + for (const item of candidates) restoreInFlight.set(item.sessionId, item); const shown = activeSessionId; - for (const [position, item] of candidates.entries()) { - if (live.has(item.sessionId) && sessionMap.has(item.sessionId) - && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true }) !== false) continue; - const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; - dormantWorkingSet.set(item.sessionId, { item, index }); + try { + const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); + for (const [position, item] of candidates.entries()) { + const reattached = live.has(item.sessionId) && sessionMap.has(item.sessionId) + && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true }) !== false; + if (!reattached) { + const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; + dormantWorkingSet.set(item.sessionId, { item, index }); + } + } + } finally { + for (const item of candidates) restoreInFlight.delete(item.sessionId); } if (shown && activeSessionId !== shown && openSessions.has(shown)) showSession(shown); refreshSidebar(); diff --git a/public/grid-view.js b/public/grid-view.js index fdf3c2497..e768ec9ba 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -525,9 +525,9 @@ function getOrderedOpenSessionIds({ includeDormant = false } = {}) { return ids; } -function navigateSession(direction) { +function navigateSession(direction, { repeat = false } = {}) { // see .ai/contexts/session-cache.md ("Restore on click") - const ids = getOrderedOpenSessionIds({ includeDormant: !gridViewActive }); + const ids = getOrderedOpenSessionIds({ includeDormant: !gridViewActive && !repeat }); const current = gridViewActive ? gridFocusedSessionId : activeSessionId; const idx = ids.indexOf(current); let next; @@ -540,7 +540,7 @@ function navigateSession(direction) { if (gridViewActive) { focusGridCard(next); } else if (isDormantSession(next) && sessionMap.has(next)) { - openSession(sessionMap.get(next)); + openSession(sessionMap.get(next)).catch(err => console.error('[navigateSession]', err)); } else { showSession(next); } @@ -615,7 +615,7 @@ function handleSessionNavKey(e) { // Prev/next session (default Cmd/Ctrl+Shift+[ / ]) if (matchShortcut('sessionNavBrackets', e, isMac, appShortcuts)) { e.preventDefault(); - if (e.type === 'keydown') navigateSession(e.code === 'BracketLeft' ? -1 : 1); + if (e.type === 'keydown') navigateSession(e.code === 'BracketLeft' ? -1 : 1, { repeat: e.repeat }); return true; } @@ -628,7 +628,7 @@ function handleSessionNavKey(e) { navigateGrid(dirMap[e.key]); } else { const dir = (e.key === 'ArrowLeft' || e.key === 'ArrowUp') ? -1 : 1; - navigateSession(dir); + navigateSession(dir, { repeat: e.repeat }); } } return true; diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 6eef75eb8..57b07395d 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -447,3 +447,58 @@ test('lazy sidebar: a group built again after the marking shows its dormant rows assert.equal(h.row('w').closest('.worktree-sessions').previousElementSibling.classList.contains('collapsed'), false, 'worktree'); } finally { h.destroy(); } }); + +test('lazy: a key held down cycles the open sessions and resumes no dormant one', async () => { + const h = setup({ savedSet: [...SAVED, { sessionId: 'c', projectPath: PROJECT, active: false }] }); + try { + h.index(['a', 'x', 'b', 'c']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + await h.open('c'); + h.run('navigateSession(1, { repeat: true }); navigateSession(1, { repeat: true });'); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['x', 'c'], 'no dormant row is resumed by an auto-repeat'); + assert.deepEqual(h.dormantIds(), ['a', 'b']); + assert.equal(h.run('activeSessionId'), 'c', 'two repeats from c go to x and back'); + } finally { h.destroy(); } +}); + +test('lazy: a persist while a running session is being reattached keeps the candidates not handled yet', async () => { + let release; + const h = setup({ livePtys: ['a'], openResult: () => new Promise((resolve) => { release = () => resolve({ ok: true, reattached: true }); }) }); + try { + h.index(['a', 'x', 'b']); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + assert.ok(release, 'the reattach of a is in flight'); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + release(); + await ticking; + assert.deepEqual(h.dormantIds(), ['b', 'x']); + assert.equal(h.run('restoreInFlight.size'), 0); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + } finally { h.destroy(); } +}); + +test('lazy: a dormant session that fails to open from the keyboard is logged, not an unhandled rejection', async () => { + const h = setup({ openResult: (id) => (id === 'b' ? Promise.reject(new Error('pty spawn failed')) : { ok: true }) }); + const unhandled = []; + const onUnhandled = (err) => unhandled.push(err); + process.on('unhandledRejection', onUnhandled); + const logged = []; + h.window.console.error = (...args) => logged.push(args.join(' ')); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + h.run('navigateSession(1)'); + for (let j = 0; j < 10; j++) await settle(); + assert.deepEqual(unhandled, []); + assert.ok(logged.some((line) => line.includes('pty spawn failed')), JSON.stringify(logged)); + } finally { + process.off('unhandledRejection', onUnhandled); + h.destroy(); + } +}); From eb13ad88f0fb5f37f901c28c308204795b224cb1 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 18:41:15 +0200 Subject: [PATCH 03/20] test(restore): continuation of a dormant session, opens during the marking, held keys A dormant session whose conversation continues under another id must not stay marked under the old id; a session opened while the marking awaits a reattach must not be marked; a click during a reattach-only open of the same id must resume it; the session keys must pass e.repeat; a reattach-only open must not follow a continuation. --- test/restore-lazy.test.js | 106 +++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 57b07395d..44d8f0ebf 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -100,7 +100,9 @@ function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, op }); const gridSrc = fs.readFileSync(path.join(PUBLIC_DIR, 'grid-view.js'), 'utf8'); vm.runInContext(['var gridViewActive = false;', 'var gridFocusedSessionId = null;', - extractFunction(gridSrc, 'getOrderedOpenSessionIds'), extractFunction(gridSrc, 'navigateSession')].join('\n'), context); + 'var isMac = false;', 'var appShortcuts = {};', 'function matchShortcut(name, e) { return e.shortcut === name; }', + extractFunction(gridSrc, 'getOrderedOpenSessionIds'), extractFunction(gridSrc, 'navigateSession'), + extractFunction(gridSrc, 'handleSessionNavKey')].join('\n'), context); context.savedSet = savedSet; run(` restoreMode = 'lazy'; @@ -502,3 +504,105 @@ test('lazy: a dormant session that fails to open from the keyboard is logged, no h.destroy(); } }); + +test('lazy: a dormant session whose conversation continues under another id is opened there, and the old id is neither dormant nor saved', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + h.window.resolveResumeSession = async (session) => (session.sessionId === 'a' ? { ...session, sessionId: 'a2' } : session); + await h.open('a'); + assert.deepEqual(h.openTerminalCalls, ['a2']); + assert.deepEqual(h.dormantIds(), ['b', 'x']); + assert.equal(h.row('a').classList.contains('dormant'), false); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a2', 'x', 'b']); + } finally { h.destroy(); } + + const failed = setup({ openResult: { ok: false, error: 'spawn failed' } }); + try { + failed.index(['a', 'x', 'b']); + await failed.fns.tickRestorePlanner(); + failed.window.resolveResumeSession = async (session) => (session.sessionId === 'a' ? { ...session, sessionId: 'a2' } : session); + await failed.open('a'); + assert.deepEqual(failed.dormantIds(), ['a2', 'b', 'x'], 'a continuation that fails to open stays marked under its new id'); + await failed.fns.persistWorkingSet(); + assert.deepEqual(failed.savedIds(), ['a2', 'x', 'b']); + } finally { failed.destroy(); } +}); + +test('lazy: a session the user opens while the marking awaits a reattach is not marked dormant', async () => { + for (const stage of ['opened', 'opening', 'checking']) { + const releases = {}; + const h = setup({ livePtys: ['a'], openResult: (id) => new Promise((resolve) => { releases[id] = () => resolve({ ok: true, reattached: id === 'a' }); }) }); + try { + h.index(['a', 'x', 'b']); + let check; + if (stage === 'checking') h.window.api.getSessionLiveElsewhere = (id) => (id === 'b' ? new Promise((resolve) => { check = () => resolve(null); }) : Promise.resolve(null)); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + const opening = h.open('b'); + for (let j = 0; j < 5; j++) await settle(); + if (stage === 'opened') { releases.b(); await opening; } + releases.a(); + for (let j = 0; j < 5; j++) await settle(); + if (stage === 'checking') { check(); for (let j = 0; j < 5; j++) await settle(); } + if (stage !== 'opened') { releases.b(); await opening; } + await ticking; + assert.deepEqual(h.dormantIds(), ['x'], stage); + assert.equal(h.row('b').classList.contains('dormant'), false, stage); + } finally { h.destroy(); } + } +}); + +test('lazy: a click during the reattach-only open of the same session opens it when no PTY is left', async () => { + let release; + const h = setup({ + livePtys: ['a'], + openResult: (_id, options) => (options.reattachOnly ? new Promise((resolve) => { release = () => resolve({ ok: false, notLive: true }); }) : { ok: true }), + }); + try { + h.index(['a', 'x', 'b']); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + const click = h.open('a'); + release(); + await ticking; + await click; + assert.deepEqual(h.openTerminalCalls, ['a', 'a']); + assert.equal(h.openTerminalOptions[1].reattachOnly, undefined, 'the click resumes it'); + assert.equal(h.window.openSessions.has('a'), true); + assert.deepEqual(h.dormantIds(), ['b', 'x']); + } finally { h.destroy(); } +}); + +test('lazy: the session keys pass an auto-repeat on, so a held key resumes no dormant one', async () => { + for (const key of [{ shortcut: 'sessionNavBrackets', code: 'BracketRight' }, { shortcut: 'sessionNavArrows', key: 'ArrowRight' }]) { + const h = setup({ savedSet: [...SAVED, { sessionId: 'c', projectPath: PROJECT, active: false }] }); + try { + h.index(['a', 'x', 'b', 'c']); + await h.fns.tickRestorePlanner(); + await h.open('x'); + await h.open('c'); + const e = { ...key, type: 'keydown', repeat: true, preventDefault() {} }; + h.window.handleSessionNavKey(e); + h.window.handleSessionNavKey(e); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['x', 'c'], key.shortcut); + } finally { h.destroy(); } + } +}); + +test('lazy: a running session is reattached under its own id, not a continuation\'s', async () => { + const h = setup({ livePtys: ['a', 'x'], openResult: { ok: true, reattached: true } }); + try { + h.index(['a', 'x', 'b']); + h.window.resolveResumeSession = async (session) => ({ ...session, sessionId: session.sessionId + '2' }); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['a', 'x']); + h.window.openSessions.clear(); + h.run('activeSessionId = "x"'); + await h.fns.reopenActiveSessionAfterReload(); + assert.deepEqual(h.openTerminalCalls, ['a', 'x', 'x'], 'and so after a reload'); + } finally { h.destroy(); } +}); From 938e7179dc356ee962b386094e7d0999a13ef616 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 18:41:15 +0200 Subject: [PATCH 04/20] fix(restore): move a dormant entry to its continuation, and skip sessions opened during the marking A click resolving a dormant session to another id moved the saved index and the skipped entry but not the dormant one, so the old id came back marked on every start. The marking now skips a candidate the user opened while it awaited a reattach, and reattach-only opens no longer follow a continuation. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 4 +++- public/app.js | 13 ++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index d0b0afe45..36b6a33c9 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again. The marking skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`). Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index 7be8d231a..a59b40dbb 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -154,7 +154,9 @@ is selected, the last active one included. - Clicking a marked session resumes it, like any other click. It stays marked until it is open: if it is live in another process and you decline to resume - it, or its terminal fails to start, it is still marked and still saved. + it, or its terminal fails to start, it is still marked and still saved. One + whose conversation continued under another id is resumed there, and the new + id takes its place in the saved set. - **Don't restore**, in a marked session's right-click menu, removes it from the saved set without starting it. So does deleting the session. - A marked session never clicked stays in the saved set, so the next start diff --git a/public/app.js b/public/app.js index 5c865cd66..e31b26cb1 100644 --- a/public/app.js +++ b/public/app.js @@ -391,7 +391,7 @@ async function reopenActiveSessionAfterReload() { if (((g && g.restoreOnStartup) || SETTING_DEFAULTS.restoreOnStartup) === 'lazy') { const live = await window.api.getActiveSessions().catch(() => []); if (!live.some(e => e.sessionId === session.sessionId)) return; - await openSession(session, undefined, { automatic: true, reattachOnly: true }); + await openSession(session, undefined, { automatic: true, reattachOnly: true, continuationResolved: true }); return; } await openSession(session, undefined, { automatic: true }); @@ -453,8 +453,9 @@ async function tickRestorePlanner() { const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); for (const [position, item] of candidates.entries()) { const reattached = live.has(item.sessionId) && sessionMap.has(item.sessionId) - && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true }) !== false; - if (!reattached) { + && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; + const open = openSessions.get(item.sessionId); + if (!reattached && (!open || open.closed)) { const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; dormantWorkingSet.set(item.sessionId, { item, index }); } @@ -1767,6 +1768,12 @@ async function openSessionNow(session, customOptions, { automatic = false, live, skippedWorkingSetEntries.set(session.sessionId, { ...held, item: { ...held.item, sessionId: session.sessionId } }); } skippedWorkingSetEntries.delete(originalId); + const dormant = dormantWorkingSet.get(originalId); + if (dormant) { + dormantWorkingSet.delete(originalId); + dormantWorkingSet.set(session.sessionId, { ...dormant, item: { ...dormant.item, sessionId: session.sessionId } }); + document.getElementById('si-' + originalId)?.classList.remove('dormant'); + } const other = openingSessions.get(session.sessionId); if (open && other && other !== open) await other.promise.catch(() => {}); if (open) openingSessions.set(session.sessionId, open); From a3ec4b0f31df0fc903a586746fd831a832156ff1 Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 14:40:03 +0200 Subject: [PATCH 05/20] fix(restore): Restore on click with fresh entries and the exit flush now on main A fresh entry (a session cleared by /clear, no transcript) has no row to click, so the marking drops it. Test harnesses declare the state the merged exit flush and /clear restore read. --- docs/session-restore.md | 4 +++- public/app.js | 1 + test/clear-pending-persist.test.js | 4 ++-- test/exit-flush.test.js | 2 +- test/restore-lazy.test.js | 17 ++++++++++++++++- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/session-restore.md b/docs/session-restore.md index a59b40dbb..5c364ae9b 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -36,7 +36,9 @@ creates a worktree, since the saved project path already is the folder to start in. Once its first prompt makes it a real session, the set is saved again with it as an ordinary entry. A session without a transcript is still left out when it runs on a remote host, or was started as a fork or in a new -worktree: starting it anew would not give back the same thing. +worktree: starting it anew would not give back the same thing. In **Restore on +click** mode a fresh entry not in the index is dropped instead, since that +mode starts nothing by itself and the entry has no row to click. ## Closing the app diff --git a/public/app.js b/public/app.js index e31b26cb1..9944377d5 100644 --- a/public/app.js +++ b/public/app.js @@ -452,6 +452,7 @@ async function tickRestorePlanner() { try { const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); for (const [position, item] of candidates.entries()) { + if (item.fresh && !sessionMap.has(item.sessionId)) continue; const reattached = live.has(item.sessionId) && sessionMap.has(item.sessionId) && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; const open = openSessions.get(item.sessionId); diff --git a/test/clear-pending-persist.test.js b/test/clear-pending-persist.test.js index 6768f716f..3121b34a1 100644 --- a/test/clear-pending-persist.test.js +++ b/test/clear-pending-persist.test.js @@ -61,7 +61,7 @@ function setup() { vm.runInContext(PLAN_SRC, ctx.context); const fns = loadAppFunctions(ctx.context, { functions: ['dedup', 'loadProjects', 'persistWorkingSet', 'pendingRestoreEntries', 'runRestore'], - declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting', 'restoreIndexingDone', 'continuationRetryCancelled'], + declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting', 'restoreIndexingDone', 'continuationRetryCancelled', 'dormantWorkingSet'], }); const open = (sessionId, { pending, remoteAlias } = {}) => { const session = { sessionId, projectPath: '/srv/app', summary: 'Earlier work', ...(remoteAlias ? { remoteAlias } : {}) }; @@ -275,7 +275,7 @@ test('a session started as a fork or in a new worktree is not saved as fresh', a vm.runInContext(PLAN_SRC, ctx.context); const fns = loadAppFunctions(ctx.context, { functions: ['launchNewSession', 'persistWorkingSet', 'pendingRestoreEntries'], - declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting'], + declarations: ['_persistChain', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restorePlanner', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], }); await fns.launchNewSession({ projectPath: '/srv/app' }, options); await fns.persistWorkingSet(); diff --git a/test/exit-flush.test.js b/test/exit-flush.test.js index 325a6c652..d4ce61788 100644 --- a/test/exit-flush.test.js +++ b/test/exit-flush.test.js @@ -34,7 +34,7 @@ function setup() { const fns = loadAppFunctions(context, { declarations: ['restoringWorkingSet', 'persistWorkingSetTimer', '_persistChain', 'exitingApp', 'exitingAppTimer', 'persistSkippedWhileExiting', 'savedWorkingSetRead', 'EXIT_FLUSH_GRACE_MS', 'restorePlanner', 'restoreSavedIndex', - 'restoreAwaitingConsent', 'restoreInFlight', 'restoreMode'], + 'restoreAwaitingConsent', 'restoreInFlight', 'restoreMode', 'dormantWorkingSet'], functions: ['persistWorkingSet', 'pendingRestoreEntries', 'schedulePersistWorkingSet', 'flushStateForExit', 'restoreWorkingSet'], }); vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'public', 'setting-defaults.js'), 'utf8'), context); diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 44d8f0ebf..ad068ac81 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -94,7 +94,7 @@ function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, op const fns = loadAppFunctions(context, { declarations: ['restoringWorkingSet', 'restorePlanner', 'restoreMode', 'restoreIndexingDone', 'sessionOpenedOutsideRestore', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', - 'dormantWorkingSet', '_persistChain', 'openingSessions', 'continuationRetryCancelled'], + 'dormantWorkingSet', '_persistChain', 'openingSessions', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting'], functions: ['dismissDormantSession', 'persistWorkingSet', 'pendingRestoreEntries', 'tickRestorePlanner', 'openSession', 'openSessionNow', 'reopenActiveSessionAfterReload'], }); @@ -606,3 +606,18 @@ test('lazy: a running session is reattached under its own id, not a continuation assert.deepEqual(h.openTerminalCalls, ['a', 'x', 'x'], 'and so after a reload'); } finally { h.destroy(); } }); + +test('lazy: a saved session with no transcript yet is neither started nor kept dormant', async () => { + const savedSet = [...SAVED, { sessionId: 'f', projectPath: PROJECT, active: false, fresh: true }]; + const h = setup({ savedSet }); + try { + h.index(['a', 'x', 'b']); + h.window.sessionMap.delete('f'); + h.run('restoreIndexingDone = true'); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, []); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x']); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + } finally { h.destroy(); } +}); From f196ad44f4cf5a05e39aaafda6a0e58d1b87afeb Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 14:40:40 +0200 Subject: [PATCH 06/20] test(restore): a dormant session whose continuation is open, or opened during the marking --- test/restore-lazy.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index ad068ac81..ed6a24a0f 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -531,6 +531,45 @@ test('lazy: a dormant session whose conversation continues under another id is o } finally { failed.destroy(); } }); +test('lazy: a dormant session whose continuation is already open shows it, and is neither dormant nor saved after it closes', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + await h.fns.tickRestorePlanner(); + h.window.sessionMap.set('a2', { sessionId: 'a2', projectPath: PROJECT }); + await h.open('a2'); + h.window.resolveResumeSession = async (session) => (session.sessionId === 'a' ? { ...session, sessionId: 'a2' } : session); + await h.open('a'); + assert.deepEqual(h.openTerminalCalls, ['a2'], 'no second terminal for the open continuation'); + assert.deepEqual(h.dormantIds(), ['b', 'x']); + assert.equal(h.row('a').classList.contains('dormant'), false); + h.window.openSessions.delete('a2'); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['x', 'b'], 'the closed continuation does not come back at the next start'); + } finally { h.destroy(); } +}); + +test('lazy: a session the user opens under its continuation while the marking awaits a reattach is not marked dormant', async () => { + const releases = {}; + const h = setup({ livePtys: ['a'], openResult: (id) => new Promise((resolve) => { releases[id] = () => resolve({ ok: true, reattached: id === 'a' }); }) }); + try { + h.index(['a', 'x', 'b']); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + h.window.resolveResumeSession = async (session) => (session.sessionId === 'b' ? { ...session, sessionId: 'b2' } : session); + const opening = h.open('b'); + for (let j = 0; j < 5; j++) await settle(); + releases.b2(); + await opening; + releases.a(); + await ticking; + assert.deepEqual(h.dormantIds(), ['x']); + assert.equal(h.row('b').classList.contains('dormant'), false); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds().sort(), ['a', 'b2', 'x']); + } finally { h.destroy(); } +}); + test('lazy: a session the user opens while the marking awaits a reattach is not marked dormant', async () => { for (const stage of ['opened', 'opening', 'checking']) { const releases = {}; From 8c5225e2dc11b140f7c49d471af3bb69b5c3a6e9 Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 14:50:11 +0200 Subject: [PATCH 07/20] fix(restore): drop a dormant entry whose continuation is already open, and skip a candidate opened under its continuation The remap moved the dormant entry onto the continuation id, and the already-open branch returned before deleting it, so a closed continuation came back at every start. The remap also leaves restoreInFlight, so the marking no longer marks a session the user opened under a continuation. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 3 ++- public/app.js | 6 ++++++ test/open-session-once.test.js | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 36b6a33c9..fb3b60c6f 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again. The marking skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`). Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. The remap also deletes the old id from `restoreInFlight`, and the marking skips a candidate no longer in it (the user opened it under a continuation while it awaited) or one the user opened while it awaited (one whose entry in `openSessions` is not `closed`). Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index 5c364ae9b..cff6bd0ff 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -158,7 +158,8 @@ is selected, the last active one included. until it is open: if it is live in another process and you decline to resume it, or its terminal fails to start, it is still marked and still saved. One whose conversation continued under another id is resumed there, and the new - id takes its place in the saved set. + id takes its place in the saved set; if that continuation is already open, + it is shown and the marked session is simply dropped from the saved set. - **Don't restore**, in a marked session's right-click menu, removes it from the saved set without starting it. So does deleting the session. - A marked session never clicked stays in the saved set, so the next start diff --git a/public/app.js b/public/app.js index 9944377d5..1ecfb6361 100644 --- a/public/app.js +++ b/public/app.js @@ -452,6 +452,7 @@ async function tickRestorePlanner() { try { const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); for (const [position, item] of candidates.entries()) { + if (!restoreInFlight.has(item.sessionId)) continue; if (item.fresh && !sessionMap.has(item.sessionId)) continue; const reattached = live.has(item.sessionId) && sessionMap.has(item.sessionId) && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; @@ -1769,6 +1770,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, skippedWorkingSetEntries.set(session.sessionId, { ...held, item: { ...held.item, sessionId: session.sessionId } }); } skippedWorkingSetEntries.delete(originalId); + restoreInFlight.delete(originalId); const dormant = dormantWorkingSet.get(originalId); if (dormant) { dormantWorkingSet.delete(originalId); @@ -1788,6 +1790,10 @@ async function openSessionNow(session, customOptions, { automatic = false, live, if (entry.closed) { destroySession(sessionId); } else { + if (dormantWorkingSet.delete(sessionId)) { + document.getElementById('si-' + sessionId)?.classList.remove('dormant'); + schedulePersistWorkingSet(); + } showSession(sessionId); return; } diff --git a/test/open-session-once.test.js b/test/open-session-once.test.js index 6f948e97e..041a5e0d2 100644 --- a/test/open-session-once.test.js +++ b/test/open-session-once.test.js @@ -58,7 +58,7 @@ function setup() { window.sessionMap.set('s1', { sessionId: 's1', projectPath: PROJECT }); window.sessionMap.set('s2', { sessionId: 's2', projectPath: PROJECT }); loadAppFunctions(ctx.context, { - declarations: ['continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet'], + declarations: ['continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet', 'restoreInFlight'], functions: ['openSession', 'openSessionNow'], }); window.renderProjects(window.cachedProjects, true); From 9857113def5d871f882852a47bbf5a6d59844198 Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 15:59:49 +0200 Subject: [PATCH 08/20] test(restore): a continuation refused during the marking stays saved, and a save during the marking writes each session once --- test/restore-lazy.test.js | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index ed6a24a0f..4f3230be6 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -570,6 +570,46 @@ test('lazy: a session the user opens under its continuation while the marking aw } finally { h.destroy(); } }); +test('lazy: a session the user opens under its continuation during the marking stays saved when that continuation is refused or fails', async () => { + for (const outcome of ['refused', 'failed']) { + const releases = {}; + const h = setup({ + livePtys: ['a'], + liveElsewhere: outcome === 'refused' ? { b2: { pid: 4242 } } : {}, + confirmAnswer: false, + openResult: (id) => (id === 'b2' + ? { ok: false, error: 'spawn failed' } + : new Promise((resolve) => { releases[id] = () => resolve({ ok: true, reattached: true }); })), + }); + try { + h.index(['a', 'x', 'b']); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + h.window.resolveResumeSession = async (session) => (session.sessionId === 'b' ? { ...session, sessionId: 'b2' } : session); + await h.open('b'); + releases.a(); + await ticking; + assert.deepEqual(h.dormantIds(), ['b2', 'x'], outcome); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds().sort(), ['a', 'b2', 'x'], outcome); + } finally { h.destroy(); } + } +}); + +test('lazy: a save while the marking awaits a later reattach writes each saved session once', async () => { + let release; + const h = setup({ livePtys: ['b'], openResult: () => new Promise((resolve) => { release = () => resolve({ ok: true, reattached: true }); }) }); + try { + h.index(['a', 'x', 'b']); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); + release(); + await ticking; + } finally { h.destroy(); } +}); + test('lazy: a session the user opens while the marking awaits a reattach is not marked dormant', async () => { for (const stage of ['opened', 'opening', 'checking']) { const releases = {}; From 8ece8ef9dba2c5b8e1033114c0ad51d992741f2e Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 16:19:42 +0200 Subject: [PATCH 09/20] fix(restore): keep a candidate opened under its continuation in flight until that open settles A click that resolves a candidate the marking has not reached to a continuation used to drop it from restoreInFlight at once, so a refused or failed continuation left it neither dormant nor saved. In Restore on click mode the entry now moves to the continuation id and, when the open settles, becomes dormant there unless the continuation opened. The marking also takes a candidate out of restoreInFlight once it is dormant, so a save during a later reattach writes it once. Other modes keep main's handling. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 4 +++- public/app.js | 19 ++++++++++++++++++- test/continuation-round5.test.js | 1 - test/continuations-harness.js | 2 +- test/open-session-once.test.js | 2 +- test/restore-lazy.test.js | 15 ++++++++++++++- test/restore-live-elsewhere.test.js | 2 +- test/schedule-live-resume.test.js | 2 +- 9 files changed, 40 insertions(+), 9 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index fb3b60c6f..87e606c80 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. The remap also deletes the old id from `restoreInFlight`, and the marking skips a candidate no longer in it (the user opened it under a continuation while it awaited) or one the user opened while it awaited (one whose entry in `openSessions` is not `closed`). Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index cff6bd0ff..f85c7ff3e 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -159,7 +159,9 @@ is selected, the last active one included. it, or its terminal fails to start, it is still marked and still saved. One whose conversation continued under another id is resumed there, and the new id takes its place in the saved set; if that continuation is already open, - it is shown and the marked session is simply dropped from the saved set. + it is shown and the marked session is simply dropped from the saved set. A + continuation declined or failing to start stays marked and saved under the + new id, also when you open it before the marking has reached it. - **Don't restore**, in a marked session's right-click menu, removes it from the saved set without starting it. So does deleting the session. - A marked session never clicked stays in the saved set, so the next start diff --git a/public/app.js b/public/app.js index 1ecfb6361..b2d2e5885 100644 --- a/public/app.js +++ b/public/app.js @@ -460,6 +460,7 @@ async function tickRestorePlanner() { if (!reattached && (!open || open.closed)) { const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; dormantWorkingSet.set(item.sessionId, { item, index }); + restoreInFlight.delete(item.sessionId); } } } finally { @@ -1739,11 +1740,22 @@ function openSession(session, customOptions, flags = {}) { } open.promise = openSessionNow(session, customOptions, flags, open).finally(() => { for (const [id, o] of openingSessions) if (o === open) openingSessions.delete(id); + if (open.continuedInFlight) settleContinuedInFlight(open.continuedInFlight); }); openingSessions.set(session.sessionId, open); return open.promise; } +// see docs/session-restore.md ("Restore on click") +function settleContinuedInFlight(item) { + restoreInFlight.delete(item.sessionId); + const entry = openSessions.get(item.sessionId); + if (entry && !entry.closed) return; + dormantWorkingSet.set(item.sessionId, { item, index: restoreSavedIndex.get(item.sessionId) ?? Number.MAX_SAFE_INTEGER }); + document.getElementById('si-' + item.sessionId)?.classList.add('dormant'); + schedulePersistWorkingSet(); +} + async function openSessionNow(session, customOptions, { automatic = false, live, continuationResolved = false, allowBgAttach = false, activate = true, activeFrom, onBlocked, reattachOnly = false } = {}, open = null) { if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; if (!automatic) continuationRetryCancelled = true; @@ -1770,7 +1782,12 @@ async function openSessionNow(session, customOptions, { automatic = false, live, skippedWorkingSetEntries.set(session.sessionId, { ...held, item: { ...held.item, sessionId: session.sessionId } }); } skippedWorkingSetEntries.delete(originalId); - restoreInFlight.delete(originalId); + const inFlight = restoreMode === 'lazy' && restoreInFlight.get(originalId); + if (inFlight && open) { + restoreInFlight.delete(originalId); + open.continuedInFlight = { ...inFlight, sessionId: session.sessionId }; + restoreInFlight.set(session.sessionId, open.continuedInFlight); + } const dormant = dormantWorkingSet.get(originalId); if (dormant) { dormantWorkingSet.delete(originalId); diff --git a/test/continuation-round5.test.js b/test/continuation-round5.test.js index 5da20c193..43c834f7f 100644 --- a/test/continuation-round5.test.js +++ b/test/continuation-round5.test.js @@ -49,7 +49,6 @@ for (const mode of ['ask', 'auto']) { vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/restore-plan.js'), 'utf8'), h.ctx); vm.runInContext('restoringWorkingSet = false; activeSessionId = "old";', h.ctx); const app = loadAppFunctions(h.ctx, { - declarations: ['restoreMode'], functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice'], }); await app.restoreStartupSessions(); diff --git a/test/continuations-harness.js b/test/continuations-harness.js index 8a221687a..a3e287eae 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -77,7 +77,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } }); dom.window.SETTING_DEFAULTS = require('../public/setting-defaults').SETTING_DEFAULTS; const app = loadAppFunctions(ctx, { - declarations: ['openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], + declarations: ['restoreMode', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], functions: ['runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice'], }); return { diff --git a/test/open-session-once.test.js b/test/open-session-once.test.js index 041a5e0d2..6ebe1af7b 100644 --- a/test/open-session-once.test.js +++ b/test/open-session-once.test.js @@ -58,7 +58,7 @@ function setup() { window.sessionMap.set('s1', { sessionId: 's1', projectPath: PROJECT }); window.sessionMap.set('s2', { sessionId: 's2', projectPath: PROJECT }); loadAppFunctions(ctx.context, { - declarations: ['continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet', 'restoreInFlight'], + declarations: ['restoreMode', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet', 'restoreInFlight'], functions: ['openSession', 'openSessionNow'], }); window.renderProjects(window.cachedProjects, true); diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 4f3230be6..0d54d5b8f 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -96,7 +96,7 @@ function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, op 'sessionOpenedOutsideRestore', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'dormantWorkingSet', '_persistChain', 'openingSessions', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting'], functions: ['dismissDormantSession', 'persistWorkingSet', 'pendingRestoreEntries', 'tickRestorePlanner', 'openSession', 'openSessionNow', - 'reopenActiveSessionAfterReload'], + 'reopenActiveSessionAfterReload', 'settleContinuedInFlight'], }); const gridSrc = fs.readFileSync(path.join(PUBLIC_DIR, 'grid-view.js'), 'utf8'); vm.runInContext(['var gridViewActive = false;', 'var gridFocusedSessionId = null;', @@ -596,6 +596,19 @@ test('lazy: a session the user opens under its continuation during the marking s } }); +test('outside Restore on click, a click resolved to a continuation leaves the restore\'s in-flight entry and marks nothing', async () => { + const h = setup({ mode: 'auto' }); + try { + h.index(['a', 'x', 'b']); + h.run('restoreMode = "auto"; restoreInFlight.set("b", savedSet[2])'); + h.window.resolveResumeSession = async (session) => (session.sessionId === 'b' ? { ...session, sessionId: 'b2' } : session); + h.window.api.openTerminal = async () => ({ ok: false, error: 'spawn failed' }); + await h.open('b'); + assert.deepEqual([...h.run('[...restoreInFlight.keys()]')], ['b']); + assert.deepEqual(h.dormantIds(), []); + } finally { h.destroy(); } +}); + test('lazy: a save while the marking awaits a later reattach writes each saved session once', async () => { let release; const h = setup({ livePtys: ['b'], openResult: () => new Promise((resolve) => { release = () => resolve({ ok: true, reattached: true }); }) }); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index e782c3fa5..84728fb82 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -96,7 +96,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { loadAppFunctions(ctx, { functions: ['rowSessionIds', 'rowHeadId', 'rowOpenId'], sourcePath: path.join(__dirname, '../public/session-activity-dom.js'), }); - vm.runInContext(topLevelDeclarations(APP_SRC, ['LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet']) + vm.runInContext(topLevelDeclarations(APP_SRC, ['restoreMode', 'LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet']) .replace(/^const /gm, 'var ').replace(/^let /gm, 'var '), ctx); for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession', 'openSessionNow']) { vm.runInContext(functionSource(APP_SRC, name), ctx); diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index 1304a6e49..776e7bea9 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -108,7 +108,7 @@ function setup(t, { sandbox = false } = {}) { }); const app = loadAppFunctions(ctx, { functions: ['openSession', 'openSessionNow', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice'], - declarations: ['LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], + declarations: ['restoreMode', 'LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], }); ctx.sessionMap.set(SID, session); t.after(() => { From 1bac309d49640cef0fde5f7da803c5fb0c8f9cd5 Mon Sep 17 00:00:00 2001 From: abate Date: Mon, 12 Oct 2026 09:20:00 +0200 Subject: [PATCH 10/20] test(restore): Restore on click with merged web-session rows, and pin the held entries and the continuation settle A saved member of a merged row has no sessionMap entry of its own: the marking, the row click, the session keys, "Don't restore", Delete and the slug-group fold must all find it through its row. --- test/restore-lazy.test.js | 173 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 0d54d5b8f..03c4d4154 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -713,3 +713,176 @@ test('lazy: a saved session with no transcript yet is neither started nor kept d assert.deepEqual(h.savedIds(), ['a', 'x', 'b']); } finally { h.destroy(); } }); + +function setupMerged({ saved = ['B', 'x'], members = ['B'], slug = false, ...opts } = {}) { + const savedSet = saved.map((sessionId) => ({ sessionId, projectPath: PROJECT, active: sessionId === 'x' })); + const h = setup({ savedSet, ...opts }); + const project = h.window.cachedProjects[0]; + const rowIds = new Set(['A', ...members]); + project.sessions = project.sessions.filter((s) => !rowIds.has(s.sessionId)); + project.sessions.push({ sessionId: 'A', name: 'web', modified: OLD, messageCount: 1, mergedMemberIds: members, ...(slug ? { slug: 'sl' } : {}) }); + if (slug) project.sessions.push({ sessionId: 'z', name: 'z', slug: 'sl', modified: OLD, messageCount: 1 }); + h.indexMerged = () => { + h.window.sessionMap.set('A', { sessionId: 'A', projectPath: PROJECT, mergedMemberIds: members }); + h.window.sessionMap.set('x', { sessionId: 'x', projectPath: PROJECT }); + }; + h.window.renderProjects(h.window.cachedProjects, true); + return h; +} + +test('lazy merged row: a saved member that is not the row\'s primary is marked and kept saved', async () => { + const h = setupMerged(); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['B', 'x']); + assert.ok(h.row('A').classList.contains('dormant'), 'the shared row is marked'); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['B', 'x']); + assert.deepEqual(h.openTerminalCalls, []); + } finally { h.destroy(); } +}); + +test('lazy merged row: a running member is reattached under its own id', async () => { + const h = setupMerged({ livePtys: ['B'] }); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['B']); + assert.equal(h.openTerminalOptions[0].reattachOnly, true); + assert.deepEqual(h.dormantIds(), ['x']); + } finally { h.destroy(); } +}); + +test('lazy merged row: a click on the row resumes its dormant member, and the row is no longer marked', async () => { + const h = setupMerged(); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + h.row('A').click(); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['B']); + assert.deepEqual(h.dormantIds(), ['x']); + assert.equal(h.row('A').classList.contains('dormant'), false); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['B', 'x']); + } finally { h.destroy(); } +}); + +test('lazy merged row: "Don\'t restore" and Delete drop the row\'s dormant member', async () => { + const h = setupMerged(); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + h.row('A').dispatchEvent(new h.window.MouseEvent('contextmenu', { bubbles: true, clientX: 5, clientY: 5 })); + const dismiss = h.window.document.querySelector('.session-dismiss-dormant-btn'); + assert.ok(dismiss, 'the menu of a row with a dormant member offers "Don\'t restore"'); + dismiss.click(); + await h.window.eval('_persistChain'); + assert.deepEqual(h.dormantIds(), ['x']); + assert.deepEqual(h.savedIds(), ['x']); + assert.deepEqual(h.openTerminalCalls, []); + } finally { h.destroy(); } + + const d = setupMerged(); + try { + d.indexMerged(); + await d.fns.tickRestorePlanner(); + d.row('A').querySelector('.session-delete-btn').click(); + await settle(); + await d.window.eval('_persistChain'); + assert.deepEqual(d.dormantIds(), ['x']); + assert.deepEqual(d.savedIds(), ['x']); + } finally { d.destroy(); } +}); + +test('lazy merged row: a slug group whose row has a dormant member is not folded under "older"', async () => { + const h = setupMerged({ slug: true }); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + const group = h.row('A').closest('.slug-group'); + assert.ok(group, 'the row is in a slug group'); + assert.equal(group.closest('.sessions-older'), null); + } finally { h.destroy(); } +}); + +test('lazy merged row: the session keys open a dormant member under its own id', async () => { + const h = setupMerged(); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + await h.open('x'); + h.run('navigateSession(1)'); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['x', 'B']); + assert.deepEqual(h.dormantIds(), []); + } finally { h.destroy(); } +}); + +test('lazy merged row: opening the row clears every dormant member of it, also when another member is already open', async () => { + const h = setupMerged({ saved: ['A', 'B', 'x'] }); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['A', 'B', 'x']); + h.row('A').click(); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['A']); + assert.deepEqual(h.dormantIds(), ['x']); + assert.equal(h.row('A').classList.contains('dormant'), false); + } finally { h.destroy(); } + + const o = setupMerged({ saved: ['A', 'x'] }); + try { + o.indexMerged(); + await o.fns.tickRestorePlanner(); + o.window.openSessions.set('B', { session: { sessionId: 'B', projectPath: PROJECT }, closed: false, terminal: {} }); + let persists = 0; + o.window.schedulePersistWorkingSet = () => { persists++; }; + o.row('A').click(); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(o.openTerminalCalls, [], 'the open member is shown, nothing is started'); + assert.equal(o.run('activeSessionId'), 'B'); + assert.deepEqual(o.dormantIds(), ['x']); + assert.equal(o.row('A').classList.contains('dormant'), false); + assert.equal(persists, 1); + } finally { o.destroy(); } +}); + +test('lazy merged row: a member continued under another id keeps the row marked while another member is still dormant', async () => { + const h = setupMerged({ saved: ['B', 'C', 'x'], members: ['B', 'C'] }); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + h.window.resolveResumeSession = async (s) => (s.sessionId === 'B' ? { ...s, sessionId: 'B2' } : s); + await h.fns.openSession({ sessionId: 'B', projectPath: PROJECT, mergedMemberIds: [] }); + assert.deepEqual(h.openTerminalCalls, ['B2']); + assert.deepEqual(h.dormantIds(), ['C', 'x']); + assert.ok(h.row('A').classList.contains('dormant'), 'C is still dormant'); + } finally { h.destroy(); } +}); + +test('a save leaves out a session the restore started and the user stopped', async () => { + const h = setup(); + try { + h.run('restoreInFlight.set("b", savedSet[2])'); + h.window.openSessions.set('b', { session: { sessionId: 'b', projectPath: PROJECT }, closed: true, terminal: {} }); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['a', 'x']); + } finally { h.destroy(); } +}); + +test('lazy: settling a continuation that did not open marks its row and schedules a save', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + h.window.refreshSidebar(); + let persists = 0; + h.window.schedulePersistWorkingSet = () => { persists++; }; + h.fns.settleContinuedInFlight({ sessionId: 'b', projectPath: PROJECT }); + assert.ok(h.row('b').classList.contains('dormant')); + assert.equal(persists, 1); + assert.deepEqual(h.dormantIds(), ['b']); + } finally { h.destroy(); } +}); From 1c4d562eddef64f5237c4fb318c6f9016155e12a Mon Sep 17 00:00:00 2001 From: abate Date: Mon, 12 Oct 2026 09:29:34 +0200 Subject: [PATCH 11/20] fix(restore): Restore on click finds a saved member of a merged web-session row The marking, the reattach and the session keys resolve a member id through its row (indexedSessionById). A row click resumes the row's dormant member, and opening or showing any member clears every dormant id of the row, since the row is then open. Row marks go through sessionItemEl and follow every id of the row. "Don't restore", Delete and the slug-group fold use rowSessionIds. The changelog entry moves to Unreleased. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 6 +++ eslint.config.js | 1 + public/app.js | 50 +++++++++++++++++------- public/grid-view.js | 4 +- public/sidebar.js | 9 +++-- test/continuation-regressions.test.js | 2 +- test/continuation-round4.test.js | 2 +- test/continuation-round5.test.js | 2 +- test/continuations-harness.js | 2 +- test/dom-merged-row-actions-r2.test.js | 2 +- test/open-session-merged-members.test.js | 2 +- test/open-session-once.test.js | 2 +- test/open-session-terminal.test.js | 2 +- test/process-exit-status.test.js | 1 + test/restore-lazy.test.js | 2 +- test/restore-live-elsewhere.test.js | 2 +- test/restore-merged-member.test.js | 4 +- test/resume-guard.test.js | 2 +- test/schedule-live-resume.test.js | 2 +- 20 files changed, 66 insertions(+), 35 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 87e606c80..a6380d886 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's dormant member; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index f85c7ff3e..adc4e4081 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -176,6 +176,12 @@ is selected, the last active one included. the open sessions only, so they never resume a row of marked sessions one after another. The grid has no card for a marked session, so in the grid they skip it. +- A saved session that is a member of a merged web-session row marks that + row. Clicking the row resumes the marked member, or shows the member already + open; either way the row is open, so every member of it is dropped from the + marked and saved sessions. **Don't restore** and deleting the row drop every + marked member of it, and the session keys resume a marked member under its + own id. - Reloading the window starts nothing. A saved session whose process is still running is reattached, the selected one included; the others are marked. A process that ends during the reload is not started again. diff --git a/eslint.config.js b/eslint.config.js index 3eb0af2b2..e838fa79f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -100,6 +100,7 @@ const rendererCrossFileGlobals = { dormantWorkingSet: 'readonly', dismissDormantSession: 'readonly', isDormantSession: 'readonly', + indexedSessionById: 'readonly', revealDormantSessions: 'readonly', sessionMap: 'writable', lastActivityTime: 'readonly', diff --git a/public/app.js b/public/app.js index b2d2e5885..43ac76d77 100644 --- a/public/app.js +++ b/public/app.js @@ -177,6 +177,28 @@ function dismissDormantSession(sessionId) { refreshSidebar(); } +function indexedSessionById(sessionId) { + const row = sessionMap.get(sessionId) || [...sessionMap.values()].find(s => s.mergedMemberIds?.includes(sessionId)); + return row && (row.sessionId === sessionId ? row : { ...row, sessionId, mergedMemberIds: [] }); +} + +function dormantRowIds(row) { + return row ? [row.dataset.sessionId, ...(row.dataset.mergedMemberIds?.split(' ') || [])] : []; +} + +function syncDormantRow(sessionId) { + const row = sessionItemEl(sessionId); + if (row) row.classList.toggle('dormant', dormantRowIds(row).some(id => dormantWorkingSet.has(id))); +} + +function clearDormantRow(sessionId) { + if (!dormantWorkingSet.size) return false; + let cleared = false; + for (const id of new Set([sessionId, ...dormantRowIds(sessionItemEl(sessionId))])) cleared = dormantWorkingSet.delete(id) || cleared; + syncDormantRow(sessionId); + return cleared; +} + // Serialise concurrent read-modify-write calls so two async persist paths // (e.g. sidebar-resize and a working-set flush arriving in the same tick) // cannot interleave and silently drop each other's keys. @@ -384,8 +406,7 @@ async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = // see .ai/contexts/session-cache.md ("Restore on click") async function reopenActiveSessionAfterReload() { if (!activeSessionId || openSessions.has(activeSessionId)) return; - const row = sessionMap.get(activeSessionId) || [...sessionMap.values()].find(s => s.mergedMemberIds?.includes(activeSessionId)); - const session = row && (row.sessionId === activeSessionId ? row : { ...row, sessionId: activeSessionId, mergedMemberIds: [] }); + const session = indexedSessionById(activeSessionId); if (!session) return; const g = await window.api.getSetting('global'); if (((g && g.restoreOnStartup) || SETTING_DEFAULTS.restoreOnStartup) === 'lazy') { @@ -453,9 +474,10 @@ async function tickRestorePlanner() { const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); for (const [position, item] of candidates.entries()) { if (!restoreInFlight.has(item.sessionId)) continue; - if (item.fresh && !sessionMap.has(item.sessionId)) continue; - const reattached = live.has(item.sessionId) && sessionMap.has(item.sessionId) - && await openSession(sessionMap.get(item.sessionId), undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; + const indexed = indexedSessionById(item.sessionId); + if (item.fresh && !indexed) continue; + const reattached = live.has(item.sessionId) && indexed + && await openSession(indexed, undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; const open = openSessions.get(item.sessionId); if (!reattached && (!open || open.closed)) { const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; @@ -1732,6 +1754,10 @@ const openingSessions = new Map(); // see .ai/contexts/session-state.md ("Opening a session once") function openSession(session, customOptions, flags = {}) { + if (!flags.automatic && session.mergedMemberIds?.length && !rowOpenId(session)) { + const dormantId = rowSessionIds(session).find(id => dormantWorkingSet.has(id)); + if (dormantId && dormantId !== session.sessionId) session = { ...session, sessionId: dormantId, mergedMemberIds: [] }; + } const inFlight = openingSessions.get(session.sessionId); const open = { byUser: !flags.automatic, after: null }; if (inFlight) { @@ -1752,7 +1778,7 @@ function settleContinuedInFlight(item) { const entry = openSessions.get(item.sessionId); if (entry && !entry.closed) return; dormantWorkingSet.set(item.sessionId, { item, index: restoreSavedIndex.get(item.sessionId) ?? Number.MAX_SAFE_INTEGER }); - document.getElementById('si-' + item.sessionId)?.classList.add('dormant'); + syncDormantRow(item.sessionId); schedulePersistWorkingSet(); } @@ -1764,6 +1790,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, ? (openSessions.has(session.sessionId) && !openSessions.get(session.sessionId).closed ? session.sessionId : null) : rowOpenId(session); if (openId) { + if (clearDormantRow(openId)) schedulePersistWorkingSet(); showSession(openId); return; } @@ -1792,7 +1819,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, if (dormant) { dormantWorkingSet.delete(originalId); dormantWorkingSet.set(session.sessionId, { ...dormant, item: { ...dormant.item, sessionId: session.sessionId } }); - document.getElementById('si-' + originalId)?.classList.remove('dormant'); + syncDormantRow(originalId); } const other = openingSessions.get(session.sessionId); if (open && other && other !== open) await other.promise.catch(() => {}); @@ -1807,10 +1834,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, if (entry.closed) { destroySession(sessionId); } else { - if (dormantWorkingSet.delete(sessionId)) { - document.getElementById('si-' + sessionId)?.classList.remove('dormant'); - schedulePersistWorkingSet(); - } + if (clearDormantRow(sessionId)) schedulePersistWorkingSet(); showSession(sessionId); return; } @@ -1861,9 +1885,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, keepAttached: true, }); } - if (dormantWorkingSet.delete(sessionId)) { - document.getElementById('si-' + sessionId)?.classList.remove('dormant'); - } + clearDormantRow(sessionId); syncPtySizeAfterOpen(entry, result); if (typeof setSessionMcpState === 'function') setSessionMcpState(sessionId, result.mcpState, result.mcpError); setSessionSandboxed(sessionId, result.sandbox); diff --git a/public/grid-view.js b/public/grid-view.js index e768ec9ba..166d0ec53 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -539,8 +539,8 @@ function navigateSession(direction, { repeat = false } = {}) { if (ids.length === 0 || !next) return; if (gridViewActive) { focusGridCard(next); - } else if (isDormantSession(next) && sessionMap.has(next)) { - openSession(sessionMap.get(next)).catch(err => console.error('[navigateSession]', err)); + } else if (isDormantSession(next) && indexedSessionById(next)) { + openSession(indexedSessionById(next)).catch(err => console.error('[navigateSession]', err)); } else { showSession(next); } diff --git a/public/sidebar.js b/public/sidebar.js index 704bb7f79..4e64f5120 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -762,7 +762,7 @@ function renderProjects(projects, resort) { const mostRecentTime = Math.max(...sessions.map(s => new Date(s.modified).getTime())); const hasRunning = sessions.some(s => rowSessionIds(s).some(id => activePtyIds.has(id) || pendingSessions.has(id))); const hasPinned = sessions.some(s => s.starred); - const hasDormant = sessions.some(s => isDormantSession(s.sessionId)); + const hasDormant = sessions.some(s => rowSessionIds(s).some(isDormantSession)); const element = sessions.length === 1 && !scheduled ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions, subagentIndex, scheduled, project, legacyScheduleScopes); allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, dormant: hasDormant, element }); } @@ -1658,7 +1658,7 @@ function rebindSidebarEvents(projects) { // The sidebar re-injects transcript-less sessions from pendingSessions on // every load, so forget it here or a deleted placeholder card comes back. if (typeof pendingSessions !== 'undefined') pendingSessions.delete(session.sessionId); - if (typeof dismissDormantSession === 'function') dismissDormantSession(session.sessionId); + if (typeof dismissDormantSession === 'function') rowSessionIds(session).forEach(dismissDormantSession); if (typeof sessionMap !== 'undefined') sessionMap.delete(session.sessionId); // Close the tab too — otherwise it stays open pointing at a transcript // that no longer exists for the rest of this run. @@ -1759,7 +1759,8 @@ function showSessionContextMenu(event, sessionId) { sendSessionToBackground(openId); }; menu.appendChild(bgButton); - if (isDormantSession(sessionId)) { + const dormantIds = rowSessionIds(sessionMap.get(sessionId) || { sessionId }).filter(isDormantSession); + if (dormantIds.length) { const dismiss = document.createElement('button'); dismiss.className = 'popover-option session-dismiss-dormant-btn'; dismiss.setAttribute('role', 'menuitem'); @@ -1767,7 +1768,7 @@ function showSessionContextMenu(event, sessionId) { dismiss.title = 'Remove this session from the sessions to restore, without starting it'; dismiss.onclick = () => { closeSessionContextMenu(); - dismissDormantSession(sessionId); + dormantIds.forEach(dismissDormantSession); }; menu.appendChild(dismiss); } diff --git a/test/continuation-regressions.test.js b/test/continuation-regressions.test.js index 6369f7277..8049fc418 100644 --- a/test/continuation-regressions.test.js +++ b/test/continuation-regressions.test.js @@ -192,7 +192,7 @@ test('R2 startup resolves an active continued session only once', { timeout: 900 const h = setup(t, { old: ['new'], new: [] }); const lookup = t.mock.method(h.ctx.api, 'getSessionContinuations'); vm.runInContext("activeSessionId = 'old';", h.ctx); - loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'loadStartupProjects'] }); + loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow', 'loadStartupProjects'] }); h.ctx.restoreWorkingSet = h.restore; h.ctx.restoreAgentsViewAtStartup = () => {}; h.ctx.loadProjects = async () => {}; diff --git a/test/continuation-round4.test.js b/test/continuation-round4.test.js index 4220879fc..73fb76cc5 100644 --- a/test/continuation-round4.test.js +++ b/test/continuation-round4.test.js @@ -160,7 +160,7 @@ test('R4 R2 remembered automatic open does not cancel the indexing retry', { tim vm.runInContext('restoringWorkingSet = false;', h.ctx); }; h.ctx.restoreAgentsViewAtStartup = () => {}; - const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload'] }); + const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); await startup.restoreStartupSessions(); await app.markRestoreIndexingDone(); assert.deepEqual(h.spawned.map(call => call.id), ['new']); diff --git a/test/continuation-round5.test.js b/test/continuation-round5.test.js index 43c834f7f..b788afd37 100644 --- a/test/continuation-round5.test.js +++ b/test/continuation-round5.test.js @@ -49,7 +49,7 @@ for (const mode of ['ask', 'auto']) { vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/restore-plan.js'), 'utf8'), h.ctx); vm.runInContext('restoringWorkingSet = false; activeSessionId = "old";', h.ctx); const app = loadAppFunctions(h.ctx, { - functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice'], + functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); await app.restoreStartupSessions(); assert.equal(h.dom.window.document.getElementById('restore-toast'), null, 'reload must not ask to restore saved sessions'); diff --git a/test/continuations-harness.js b/test/continuations-harness.js index a3e287eae..87945c341 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -78,7 +78,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } dom.window.SETTING_DEFAULTS = require('../public/setting-defaults').SETTING_DEFAULTS; const app = loadAppFunctions(ctx, { declarations: ['restoreMode', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], - functions: ['runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice'], + functions: ['runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); return { spawned, prompts, rows, root, db, folder, ctx, diff --git a/test/dom-merged-row-actions-r2.test.js b/test/dom-merged-row-actions-r2.test.js index 764d54146..ac1d24de5 100644 --- a/test/dom-merged-row-actions-r2.test.js +++ b/test/dom-merged-row-actions-r2.test.js @@ -213,7 +213,7 @@ for (const ownOpen of [false, true]) { open(ctx, 'B'); open(ctx, 'C'); if (ownOpen) open(ctx, 'A'); - const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow'], declarations: ['openingSessions'] }); + const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'dormantWorkingSet'] }); await openSession(session); assert.deepEqual(shown, ['C']); }); diff --git a/test/open-session-merged-members.test.js b/test/open-session-merged-members.test.js index 0155cf655..d4d20f3fb 100644 --- a/test/open-session-merged-members.test.js +++ b/test/open-session-merged-members.test.js @@ -21,7 +21,7 @@ function setup(t) { checked.push(id); return { candidates: [{ sessionId: 'D' }], unresolved: false }; }; - const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow'], declarations: ['openingSessions'] }); + const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'dormantWorkingSet'] }); return { ctx, openSession, shown, prompts, checked }; } diff --git a/test/open-session-once.test.js b/test/open-session-once.test.js index 6ebe1af7b..d41872b78 100644 --- a/test/open-session-once.test.js +++ b/test/open-session-once.test.js @@ -59,7 +59,7 @@ function setup() { window.sessionMap.set('s2', { sessionId: 's2', projectPath: PROJECT }); loadAppFunctions(ctx.context, { declarations: ['restoreMode', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet', 'restoreInFlight'], - functions: ['openSession', 'openSessionNow'], + functions: ['openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); window.renderProjects(window.cachedProjects, true); const row = () => window.document.getElementById('si-s1'); diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index 019130f3e..8590bf583 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -52,7 +52,7 @@ async function withHarness(setup, fn) { setup(window, calls); const { openSession } = loadAppFunctions(ctx.context, { declarations: ['ptyGenerations', 'pendingOpens', 'continuationRetryCancelled', 'openingSessions'], - functions: ['beginPtyOpen', 'settlePtyOpen', 'openSession', 'openSessionNow'], + functions: ['beginPtyOpen', 'settlePtyOpen', 'openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); await fn({ openSession, window, calls }); } finally { diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index e6fdeeaaf..87b51c9f4 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -109,6 +109,7 @@ function setup() { run(extractDeclaration(APP_SRC, 'openingSessions'), 'app.js#openingSessions'); run(sliceBlock('function openSession(session'), 'app.js#openSession'); run(sliceBlock('async function openSessionNow(session'), 'app.js#openSessionNow'); + run(sliceBlock('function clearDormantRow('), 'app.js#clearDormantRow'); run(sliceBlock('function applyProcessExit('), 'app.js#applyProcessExit'); run(sliceBlock('function handleProcessExited('), 'app.js#handleProcessExited'); run(sliceBlock('function beginPtyOpen('), 'app.js#beginPtyOpen'); diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 03c4d4154..e16be2890 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -96,7 +96,7 @@ function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, op 'sessionOpenedOutsideRestore', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'dormantWorkingSet', '_persistChain', 'openingSessions', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting'], functions: ['dismissDormantSession', 'persistWorkingSet', 'pendingRestoreEntries', 'tickRestorePlanner', 'openSession', 'openSessionNow', - 'reopenActiveSessionAfterReload', 'settleContinuedInFlight'], + 'reopenActiveSessionAfterReload', 'settleContinuedInFlight', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); const gridSrc = fs.readFileSync(path.join(PUBLIC_DIR, 'grid-view.js'), 'utf8'); vm.runInContext(['var gridViewActive = false;', 'var gridFocusedSessionId = null;', diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 84728fb82..a7f1a6987 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -98,7 +98,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { }); vm.runInContext(topLevelDeclarations(APP_SRC, ['restoreMode', 'LIVE_ELSEWHERE_NOTICE_MS', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet']) .replace(/^const /gm, 'var ').replace(/^let /gm, 'var '), ctx); - for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession', 'openSessionNow']) { + for (const name of ['persistWorkingSet', 'pendingRestoreEntries', 'runRestore', 'openSession', 'openSessionNow', 'clearDormantRow']) { vm.runInContext(functionSource(APP_SRC, name), ctx); } if (APP_SRC.includes('function showLiveElsewhereNotice(')) { diff --git a/test/restore-merged-member.test.js b/test/restore-merged-member.test.js index e7b488967..45264aeb6 100644 --- a/test/restore-merged-member.test.js +++ b/test/restore-merged-member.test.js @@ -63,7 +63,7 @@ function setup(t, { id = 'B', ids = [id], members = ['B'], headSessionId, mode = 'dedup', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showRestoreNotice', 'showNotRestoredNotice', 'showLiveElsewhereNotice', 'showColdCacheNotice', - 'restoreStartupSessions', 'reopenActiveSessionAfterReload', + 'restoreStartupSessions', 'reopenActiveSessionAfterReload',, 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow' ], }); Object.assign(h.window, { @@ -217,7 +217,7 @@ test('a remembered active id absent from all rows only restores the working set' restoreWorkingSet: async () => { calls.push('working set'); }, restoreAgentsViewAtStartup() {}, }); - const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload'] }); + const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); await assert.doesNotReject(restoreStartupSessions); assert.deepEqual(calls, ['working set']); assert.equal(ctx.window._startupResumeResolutions, null); diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index 06cb9b8a4..c41ec53d8 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -166,7 +166,7 @@ test('the reload path resumes the remembered session as an automatic resume', as restoreWorkingSet: async () => { calls.push('working set'); }, restoreAgentsViewAtStartup() {}, }); - const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload'] }); + const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); await restoreStartupSessions(); assert.deepEqual(calls, [ { session: SESSION, options: undefined, automatic: true }, diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index 776e7bea9..144bae633 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -107,7 +107,7 @@ function setup(t, { sandbox = false } = {}) { functions: ['rowSessionIds', 'rowHeadId', 'rowOpenId'], sourcePath: path.join(ROOT, 'public/session-activity-dom.js'), }); const app = loadAppFunctions(ctx, { - functions: ['openSession', 'openSessionNow', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice'], + functions: ['openSession', 'openSessionNow', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['restoreMode', 'LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], }); ctx.sessionMap.set(SID, session); From 1fd88a118cf75d0e707c7cfd8e2c1e2da91f2802 Mon Sep 17 00:00:00 2001 From: abate Date: Mon, 12 Oct 2026 15:37:04 +0200 Subject: [PATCH 12/20] test(restore): a merged row with a reattached member keeps no dormant member, whatever the saved order After a reload, a later saved member of a row whose other member was reattached is still marked, and the session keys then resume it beside the running one. The result also depends on the saved order. --- test/restore-lazy.test.js | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index e16be2890..10b34d31a 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -725,6 +725,7 @@ function setupMerged({ saved = ['B', 'x'], members = ['B'], slug = false, ...opt h.indexMerged = () => { h.window.sessionMap.set('A', { sessionId: 'A', projectPath: PROJECT, mergedMemberIds: members }); h.window.sessionMap.set('x', { sessionId: 'x', projectPath: PROJECT }); + h.window.indexLocalMergedMemberRows([{ sessions: [h.window.sessionMap.get('A')] }]); }; h.window.renderProjects(h.window.cachedProjects, true); return h; @@ -863,6 +864,45 @@ test('lazy merged row: a member continued under another id keeps the row marked } finally { h.destroy(); } }); +test('lazy merged row: a member whose row has a reattached member is not marked, whatever the saved order', async () => { + for (const [saved, live] of [[['A', 'B', 'x'], 'A'], [['B', 'A', 'x'], 'A'], [['B', 'A', 'x'], 'B'], [['A', 'B', 'x'], 'B']]) { + const label = `saved ${saved.join(',')} with ${live} running`; + const h = setupMerged({ saved, livePtys: [live] }); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, [live], label); + assert.deepEqual(h.dormantIds(), ['x'], label); + assert.equal(h.row('A').classList.contains('dormant'), false, label); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), [live, 'x'], label); + h.run(`activeSessionId = ${JSON.stringify(live)}`); + for (let i = 0; i < 3; i++) { + h.run('navigateSession(1)'); + for (let j = 0; j < 5; j++) await settle(); + } + assert.deepEqual(h.openTerminalCalls, [live, 'x'], `${label}: the session keys start nothing for the row`); + } finally { h.destroy(); } + } +}); + +test('lazy merged row: the session keys on a dormant member of a row with an open member show that member and start nothing', async () => { + const h = setupMerged(); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['B', 'x']); + h.window.openSessions.set('A', { session: { sessionId: 'A', projectPath: PROJECT }, closed: false, terminal: {} }); + h.run('activeSessionId = "A"'); + h.run('navigateSession(1)'); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, []); + assert.equal(h.run('activeSessionId'), 'A'); + assert.deepEqual(h.dormantIds(), ['x']); + assert.equal(h.row('A').classList.contains('dormant'), false); + } finally { h.destroy(); } +}); + test('a save leaves out a session the restore started and the user stopped', async () => { const h = setup(); try { From d48b537a84f43b03afc09d7fc072183c45fda247 Mon Sep 17 00:00:00 2001 From: abate Date: Mon, 12 Oct 2026 15:46:52 +0200 Subject: [PATCH 13/20] fix(restore): a member of a merged row with an open member is not marked, and the session keys show that member The marking now clears a candidate whose row already has an open member, so the result no longer depends on the saved order, and navigateSession opens the whole row for such a member instead of resuming it beside the running one. indexedRowOf maps a member to its row through localMergedMemberRows instead of scanning sessionMap. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 4 +++- eslint.config.js | 2 ++ public/app.js | 13 +++++++++++-- public/grid-view.js | 5 +++-- test/continuation-regressions.test.js | 2 +- test/continuation-round4.test.js | 2 +- test/continuation-round5.test.js | 2 +- test/continuations-harness.js | 2 +- test/dom-merged-row-actions-r2.test.js | 2 +- test/open-session-merged-members.test.js | 2 +- test/open-session-once.test.js | 2 +- test/open-session-terminal.test.js | 2 +- test/restore-lazy.test.js | 2 +- test/restore-merged-member.test.js | 5 +++-- test/resume-guard.test.js | 2 +- test/schedule-live-resume.test.js | 2 +- 17 files changed, 34 insertions(+), 19 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index a6380d886..e518a3c8e 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's dormant member; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member (`rowOpenId`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant, and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's dormant member; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index adc4e4081..f5c8eaa01 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -181,7 +181,9 @@ is selected, the last active one included. open; either way the row is open, so every member of it is dropped from the marked and saved sessions. **Don't restore** and deleting the row drop every marked member of it, and the session keys resume a marked member under its - own id. + own id, or show the member already open. After a reload, a member whose row + already has a running member is not marked, whatever its place in the saved + set: the row is open. - Reloading the window starts nothing. A saved session whose process is still running is reattached, the selected one included; the others are marked. A process that ends during the reload is not started again. diff --git a/eslint.config.js b/eslint.config.js index e838fa79f..5e24d047e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -101,6 +101,7 @@ const rendererCrossFileGlobals = { dismissDormantSession: 'readonly', isDormantSession: 'readonly', indexedSessionById: 'readonly', + indexedRowOf: 'readonly', revealDormantSessions: 'readonly', sessionMap: 'writable', lastActivityTime: 'readonly', @@ -198,6 +199,7 @@ const rendererCrossFileGlobals = { rowSessionIds: 'readonly', rowHeadId: 'readonly', rowOpenId: 'readonly', + localMergedMemberRows: 'readonly', mergedRowSnapshot: 'readonly', storedActivitySnapshot: 'readonly', snapshotForLocal: 'readonly', diff --git a/public/app.js b/public/app.js index 43ac76d77..62be33a61 100644 --- a/public/app.js +++ b/public/app.js @@ -177,8 +177,13 @@ function dismissDormantSession(sessionId) { refreshSidebar(); } +function indexedRowOf(sessionId) { + return localMergedMemberRows.get(sessionId) || sessionMap.get(sessionId) + || [...sessionMap.values()].find(s => s.mergedMemberIds?.includes(sessionId)); +} + function indexedSessionById(sessionId) { - const row = sessionMap.get(sessionId) || [...sessionMap.values()].find(s => s.mergedMemberIds?.includes(sessionId)); + const row = sessionMap.get(sessionId) || indexedRowOf(sessionId); return row && (row.sessionId === sessionId ? row : { ...row, sessionId, mergedMemberIds: [] }); } @@ -479,7 +484,11 @@ async function tickRestorePlanner() { const reattached = live.has(item.sessionId) && indexed && await openSession(indexed, undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; const open = openSessions.get(item.sessionId); - if (!reattached && (!open || open.closed)) { + const row = indexedRowOf(item.sessionId); + if (!reattached && row && rowOpenId(row)) { + clearDormantRow(item.sessionId); + restoreInFlight.delete(item.sessionId); + } else if (!reattached && (!open || open.closed)) { const index = restoreSavedIndex.has(item.sessionId) ? restoreSavedIndex.get(item.sessionId) : position; dormantWorkingSet.set(item.sessionId, { item, index }); restoreInFlight.delete(item.sessionId); diff --git a/public/grid-view.js b/public/grid-view.js index 166d0ec53..9ff3bfc63 100644 --- a/public/grid-view.js +++ b/public/grid-view.js @@ -539,8 +539,9 @@ function navigateSession(direction, { repeat = false } = {}) { if (ids.length === 0 || !next) return; if (gridViewActive) { focusGridCard(next); - } else if (isDormantSession(next) && indexedSessionById(next)) { - openSession(indexedSessionById(next)).catch(err => console.error('[navigateSession]', err)); + } else if (isDormantSession(next) && indexedRowOf(next)) { + const row = indexedRowOf(next); + openSession(rowOpenId(row) ? row : indexedSessionById(next)).catch(err => console.error('[navigateSession]', err)); } else { showSession(next); } diff --git a/test/continuation-regressions.test.js b/test/continuation-regressions.test.js index 8049fc418..aa7952155 100644 --- a/test/continuation-regressions.test.js +++ b/test/continuation-regressions.test.js @@ -192,7 +192,7 @@ test('R2 startup resolves an active continued session only once', { timeout: 900 const h = setup(t, { old: ['new'], new: [] }); const lookup = t.mock.method(h.ctx.api, 'getSessionContinuations'); vm.runInContext("activeSessionId = 'old';", h.ctx); - loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow', 'loadStartupProjects'] }); + loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow', 'loadStartupProjects'] }); h.ctx.restoreWorkingSet = h.restore; h.ctx.restoreAgentsViewAtStartup = () => {}; h.ctx.loadProjects = async () => {}; diff --git a/test/continuation-round4.test.js b/test/continuation-round4.test.js index 73fb76cc5..33f947174 100644 --- a/test/continuation-round4.test.js +++ b/test/continuation-round4.test.js @@ -160,7 +160,7 @@ test('R4 R2 remembered automatic open does not cancel the indexing retry', { tim vm.runInContext('restoringWorkingSet = false;', h.ctx); }; h.ctx.restoreAgentsViewAtStartup = () => {}; - const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); + const startup = loadAppFunctions(h.ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); await startup.restoreStartupSessions(); await app.markRestoreIndexingDone(); assert.deepEqual(h.spawned.map(call => call.id), ['new']); diff --git a/test/continuation-round5.test.js b/test/continuation-round5.test.js index b788afd37..512dccc9d 100644 --- a/test/continuation-round5.test.js +++ b/test/continuation-round5.test.js @@ -49,7 +49,7 @@ for (const mode of ['ask', 'auto']) { vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/restore-plan.js'), 'utf8'), h.ctx); vm.runInContext('restoringWorkingSet = false; activeSessionId = "old";', h.ctx); const app = loadAppFunctions(h.ctx, { - functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], + functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'showColdCacheNotice', 'showNotRestoredNotice', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); await app.restoreStartupSessions(); assert.equal(h.dom.window.document.getElementById('restore-toast'), null, 'reload must not ask to restore saved sessions'); diff --git a/test/continuations-harness.js b/test/continuations-harness.js index 87945c341..9bf6a80ca 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -78,7 +78,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } dom.window.SETTING_DEFAULTS = require('../public/setting-defaults').SETTING_DEFAULTS; const app = loadAppFunctions(ctx, { declarations: ['restoreMode', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], - functions: ['runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], + functions: ['runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showLiveElsewhereNotice', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); return { spawned, prompts, rows, root, db, folder, ctx, diff --git a/test/dom-merged-row-actions-r2.test.js b/test/dom-merged-row-actions-r2.test.js index ac1d24de5..c88ce5c46 100644 --- a/test/dom-merged-row-actions-r2.test.js +++ b/test/dom-merged-row-actions-r2.test.js @@ -213,7 +213,7 @@ for (const ownOpen of [false, true]) { open(ctx, 'B'); open(ctx, 'C'); if (ownOpen) open(ctx, 'A'); - const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'dormantWorkingSet'] }); + const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'dormantWorkingSet'] }); await openSession(session); assert.deepEqual(shown, ['C']); }); diff --git a/test/open-session-merged-members.test.js b/test/open-session-merged-members.test.js index d4d20f3fb..66c7d3cc1 100644 --- a/test/open-session-merged-members.test.js +++ b/test/open-session-merged-members.test.js @@ -21,7 +21,7 @@ function setup(t) { checked.push(id); return { candidates: [{ sessionId: 'D' }], unresolved: false }; }; - const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'dormantWorkingSet'] }); + const { openSession } = loadAppFunctions(ctx.context, { functions: ['openSession', 'openSessionNow', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'dormantWorkingSet'] }); return { ctx, openSession, shown, prompts, checked }; } diff --git a/test/open-session-once.test.js b/test/open-session-once.test.js index d41872b78..1e895c986 100644 --- a/test/open-session-once.test.js +++ b/test/open-session-once.test.js @@ -59,7 +59,7 @@ function setup() { window.sessionMap.set('s2', { sessionId: 's2', projectPath: PROJECT }); loadAppFunctions(ctx.context, { declarations: ['restoreMode', 'continuationRetryCancelled', 'openingSessions', 'dormantWorkingSet', 'restoreInFlight'], - functions: ['openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], + functions: ['openSession', 'openSessionNow', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); window.renderProjects(window.cachedProjects, true); const row = () => window.document.getElementById('si-s1'); diff --git a/test/open-session-terminal.test.js b/test/open-session-terminal.test.js index 8590bf583..aadd48036 100644 --- a/test/open-session-terminal.test.js +++ b/test/open-session-terminal.test.js @@ -52,7 +52,7 @@ async function withHarness(setup, fn) { setup(window, calls); const { openSession } = loadAppFunctions(ctx.context, { declarations: ['ptyGenerations', 'pendingOpens', 'continuationRetryCancelled', 'openingSessions'], - functions: ['beginPtyOpen', 'settlePtyOpen', 'openSession', 'openSessionNow', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], + functions: ['beginPtyOpen', 'settlePtyOpen', 'openSession', 'openSessionNow', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); await fn({ openSession, window, calls }); } finally { diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 10b34d31a..9a70717b1 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -96,7 +96,7 @@ function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, op 'sessionOpenedOutsideRestore', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'dormantWorkingSet', '_persistChain', 'openingSessions', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting'], functions: ['dismissDormantSession', 'persistWorkingSet', 'pendingRestoreEntries', 'tickRestorePlanner', 'openSession', 'openSessionNow', - 'reopenActiveSessionAfterReload', 'settleContinuedInFlight', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], + 'reopenActiveSessionAfterReload', 'settleContinuedInFlight', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], }); const gridSrc = fs.readFileSync(path.join(PUBLIC_DIR, 'grid-view.js'), 'utf8'); vm.runInContext(['var gridViewActive = false;', 'var gridFocusedSessionId = null;', diff --git a/test/restore-merged-member.test.js b/test/restore-merged-member.test.js index 45264aeb6..537110f23 100644 --- a/test/restore-merged-member.test.js +++ b/test/restore-merged-member.test.js @@ -63,7 +63,7 @@ function setup(t, { id = 'B', ids = [id], members = ['B'], headSessionId, mode = 'dedup', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showRestoreNotice', 'showNotRestoredNotice', 'showLiveElsewhereNotice', 'showColdCacheNotice', - 'restoreStartupSessions', 'reopenActiveSessionAfterReload',, 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow' + 'restoreStartupSessions', 'reopenActiveSessionAfterReload',, 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow' ], }); Object.assign(h.window, { @@ -213,11 +213,12 @@ test('a remembered active id absent from all rows only restores the working set' activeSessionId: 'missing', openSessions: new Map(), sessionMap: new Map([['A', { sessionId: 'A', mergedMemberIds: ['B'] }]]), + localMergedMemberRows: new Map(), openSession: async session => { calls.push(session.sessionId); }, restoreWorkingSet: async () => { calls.push('working set'); }, restoreAgentsViewAtStartup() {}, }); - const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); + const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); await assert.doesNotReject(restoreStartupSessions); assert.deepEqual(calls, ['working set']); assert.equal(ctx.window._startupResumeResolutions, null); diff --git a/test/resume-guard.test.js b/test/resume-guard.test.js index c41ec53d8..337c355d3 100644 --- a/test/resume-guard.test.js +++ b/test/resume-guard.test.js @@ -166,7 +166,7 @@ test('the reload path resumes the remembered session as an automatic resume', as restoreWorkingSet: async () => { calls.push('working set'); }, restoreAgentsViewAtStartup() {}, }); - const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); + const { restoreStartupSessions } = loadAppFunctions(ctx, { functions: ['restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'] }); await restoreStartupSessions(); assert.deepEqual(calls, [ { session: SESSION, options: undefined, automatic: true }, diff --git a/test/schedule-live-resume.test.js b/test/schedule-live-resume.test.js index 144bae633..027466e6f 100644 --- a/test/schedule-live-resume.test.js +++ b/test/schedule-live-resume.test.js @@ -107,7 +107,7 @@ function setup(t, { sandbox = false } = {}) { functions: ['rowSessionIds', 'rowHeadId', 'rowOpenId'], sourcePath: path.join(ROOT, 'public/session-activity-dom.js'), }); const app = loadAppFunctions(ctx, { - functions: ['openSession', 'openSessionNow', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], + functions: ['openSession', 'openSessionNow', 'runRestore', 'pendingRestoreEntries', 'persistWorkingSet', 'showRestoreNotice', 'showLiveElsewhereNotice', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['restoreMode', 'LIVE_ELSEWHERE_NOTICE_MS', 'openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', 'restoreAwaitingConsent', 'restoreInFlight', 'restoreIndexingDone', 'continuationRetryCancelled', 'exitingApp', 'persistSkippedWhileExiting', 'dormantWorkingSet'], }); ctx.sessionMap.set(SID, session); From b4757b23564181cc3520dd712f810c1b573494b5 Mon Sep 17 00:00:00 2001 From: abate Date: Tue, 13 Oct 2026 10:38:12 +0200 Subject: [PATCH 14/20] test(restore): a merged-row member is marked while a sibling's open is in flight, and a later member clears the members marked earlier --- test/restore-lazy.test.js | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 9a70717b1..84031568a 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -886,6 +886,63 @@ test('lazy merged row: a member whose row has a reattached member is not marked, } }); +test('lazy merged row: a member is marked while a sibling opened during the marking is still opening, and kept saved if that open fails', async () => { + for (const ok of [false, true]) { + const label = ok ? 'A opens' : 'A fails'; + let finishOpen; + const h = setupMerged({ saved: ['A', 'B', 'x'], openResult: () => new Promise((resolve) => { finishOpen = resolve; }) }); + try { + h.indexMerged(); + h.window.renderProjects(h.window.cachedProjects, true); + let clicked = false; + h.window.api.getActiveSessions = async () => { + if (clicked) return []; + clicked = true; + h.row('A').click(); + for (let j = 0; j < 10; j++) await settle(); + return []; + }; + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['A'], label); + assert.deepEqual(h.dormantIds(), ['B', 'x'], label); + finishOpen(ok ? { ok: true } : { ok: false, error: 'boom' }); + for (let j = 0; j < 5; j++) await settle(); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.dormantIds(), ok ? ['x'] : ['B', 'x'], label); + assert.deepEqual(h.savedIds(), ok ? ['A', 'x'] : ['B', 'x'], label); + assert.equal(h.row('A').classList.contains('dormant'), !ok, label); + } finally { h.destroy(); } + } +}); + +test('lazy merged row: a member indexed into a row whose open member was opened before the merge clears the members marked earlier', async () => { + const savedSet = ['B', 'C', 'x'].map((sessionId) => ({ sessionId, projectPath: PROJECT, active: sessionId === 'x' })); + const h = setup({ savedSet }); + try { + const project = h.window.cachedProjects[0]; + project.sessions.push({ sessionId: 'A', name: 'web', modified: OLD, messageCount: 1 }); + h.window.renderProjects(h.window.cachedProjects, true); + h.window.sessionMap.set('A', { sessionId: 'A', projectPath: PROJECT }); + h.index(['B', 'x']); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['B', 'x']); + await h.open('A'); + assert.deepEqual(h.dormantIds(), ['B', 'x'], 'A and B are not one row yet'); + + project.sessions = project.sessions.filter((s) => !['A', 'B', 'C'].includes(s.sessionId)); + project.sessions.push({ sessionId: 'A', name: 'web', modified: OLD, messageCount: 1, mergedMemberIds: ['B', 'C'] }); + h.window.renderProjects(h.window.cachedProjects, true); + h.window.sessionMap.set('A', { sessionId: 'A', projectPath: PROJECT, mergedMemberIds: ['B', 'C'] }); + h.window.indexLocalMergedMemberRows([{ sessions: [h.window.sessionMap.get('A')] }]); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['A']); + assert.deepEqual(h.dormantIds(), ['x']); + assert.equal(h.row('A').classList.contains('dormant'), false); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.savedIds(), ['A', 'x']); + } finally { h.destroy(); } +}); + test('lazy merged row: the session keys on a dormant member of a row with an open member show that member and start nothing', async () => { const h = setupMerged(); try { From 0904b19d3ab24aa7014a4cceae41a94f5aad76fd Mon Sep 17 00:00:00 2001 From: abate Date: Tue, 13 Oct 2026 10:39:56 +0200 Subject: [PATCH 15/20] fix(restore): a merged-row member is marked while a sibling's open is still in flight A member opened during the marking counted as open before its openTerminal settled, so a failed open lost the other saved members of its row. --- .ai/contexts/session-cache.md | 2 +- public/app.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index e518a3c8e..61dfcb5a2 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member (`rowOpenId`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant, and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's dormant member; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member whose open has settled (open and not `closed` in `openSessions`, and not in `openingSessions`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant; a member whose open is still in flight (a click during the `getActiveSessions` await) does not count, so the candidate is marked, stays marked and saved if that open fails, and is dropped by the open's own `clearDormantRow` if it succeeds. That clear also drops the members of the row an earlier tick marked, which happens when they were indexed into the row after its open member opened; and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's dormant member; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/public/app.js b/public/app.js index 62be33a61..d7016e555 100644 --- a/public/app.js +++ b/public/app.js @@ -485,7 +485,8 @@ async function tickRestorePlanner() { && await openSession(indexed, undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; const open = openSessions.get(item.sessionId); const row = indexedRowOf(item.sessionId); - if (!reattached && row && rowOpenId(row)) { + const rowOpen = row && rowSessionIds(row).some(id => !openingSessions.has(id) && openSessions.has(id) && !openSessions.get(id).closed); + if (!reattached && rowOpen) { clearDormantRow(item.sessionId); restoreInFlight.delete(item.sessionId); } else if (!reattached && (!open || open.closed)) { From b87f268b611cce4603c1c6b803249775d00b0c3d Mon Sep 17 00:00:00 2001 From: abate Date: Wed, 14 Oct 2026 11:14:55 +0200 Subject: [PATCH 16/20] test(restore): load the Restore on click state in tests main added, and answer the in-app confirm --- test/app-projects-failure.test.js | 2 +- test/restore-lazy.test.js | 2 +- test/restore-merged-member.test.js | 2 +- test/row-head-opening.test.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/app-projects-failure.test.js b/test/app-projects-failure.test.js index 51deea600..68f6bb3db 100644 --- a/test/app-projects-failure.test.js +++ b/test/app-projects-failure.test.js @@ -81,7 +81,7 @@ test('scheduled persistence cannot overwrite the saved working set before startu h.window.pendingRestoreEntries = () => []; loadAppFunctions(h.context, { functions: ['persistWorkingSet', 'schedulePersistWorkingSet'], - declarations: ['savedWorkingSetRead', 'persistWorkingSetTimer', 'restoringWorkingSet', 'exitingApp', 'persistSkippedWhileExiting', 'skippedWorkingSetEntries'], + declarations: ['savedWorkingSetRead', 'persistWorkingSetTimer', 'restoringWorkingSet', 'exitingApp', 'persistSkippedWhileExiting', 'skippedWorkingSetEntries', 'dormantWorkingSet'], }); try { h.window.schedulePersistWorkingSet(); diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 84031568a..11b223239 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -48,7 +48,7 @@ function setup({ savedSet = SAVED, liveElsewhere = {}, confirmAnswer = false, op return typeof openResult === 'function' ? openResult(sessionId, options) : openResult; }, }); - window.confirm = (msg) => { confirms.push(msg); return confirmAnswer; }; + window.appConfirm = async ({ message }) => { confirms.push(message); return confirmAnswer; }; const project = { projectPath: PROJECT, diff --git a/test/restore-merged-member.test.js b/test/restore-merged-member.test.js index 537110f23..a6963b244 100644 --- a/test/restore-merged-member.test.js +++ b/test/restore-merged-member.test.js @@ -63,7 +63,7 @@ function setup(t, { id = 'B', ids = [id], members = ['B'], headSessionId, mode = 'dedup', 'restoreWorkingSet', 'tickRestorePlanner', 'markRestoreIndexingDone', 'runRestore', 'openSession', 'openSessionNow', 'persistWorkingSet', 'pendingRestoreEntries', 'showRestoreNotice', 'showNotRestoredNotice', 'showLiveElsewhereNotice', 'showColdCacheNotice', - 'restoreStartupSessions', 'reopenActiveSessionAfterReload',, 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow' + 'restoreStartupSessions', 'reopenActiveSessionAfterReload', 'indexedRowOf', 'indexedSessionById', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow' ], }); Object.assign(h.window, { diff --git a/test/row-head-opening.test.js b/test/row-head-opening.test.js index 1a03ac9c0..800f5eae4 100644 --- a/test/row-head-opening.test.js +++ b/test/row-head-opening.test.js @@ -46,9 +46,9 @@ function setup(t, { session = row, live = {}, candidates = [], confirm = false } openTerminal: async id => { spawned.push(id); return { ok: true }; }, }); const app = loadAppFunctions(ctx.context, { - functions: ['openSession', 'openSessionNow', 'beginPtyOpen', 'settlePtyOpen'], + functions: ['openSession', 'openSessionNow', 'beginPtyOpen', 'settlePtyOpen', 'dormantRowIds', 'syncDormantRow', 'clearDormantRow'], declarations: ['openingSessions', 'skippedWorkingSetEntries', 'restoreSavedIndex', - 'continuationRetryCancelled', 'ptyGenerations', 'pendingOpens'], + 'continuationRetryCancelled', 'ptyGenerations', 'pendingOpens', 'dormantWorkingSet', 'restoreMode', 'restoreInFlight'], }); ctx.window.sessionMap.set(session.sessionId, session); ctx.sidebar.renderProjects([makeSampleProject({ projectPath: session.projectPath, remoteAlias: session.remoteAlias, sessions: [session] })], true); From c499bc4e96ba6f2851c69641bca5c19d8a7a6ebc Mon Sep 17 00:00:00 2001 From: abate Date: Wed, 14 Oct 2026 11:16:47 +0200 Subject: [PATCH 17/20] test(restore): an open in flight during the marking, or under a second open of its row, keeps the saved sessions when it fails --- test/restore-lazy.test.js | 90 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 11b223239..326cda034 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -886,7 +886,7 @@ test('lazy merged row: a member whose row has a reattached member is not marked, } }); -test('lazy merged row: a member is marked while a sibling opened during the marking is still opening, and kept saved if that open fails', async () => { +test('lazy merged row: a member and the sibling opened during the marking are marked while that open is in flight, and kept saved if it fails', async () => { for (const ok of [false, true]) { const label = ok ? 'A opens' : 'A fails'; let finishOpen; @@ -904,12 +904,12 @@ test('lazy merged row: a member is marked while a sibling opened during the mark }; await h.fns.tickRestorePlanner(); assert.deepEqual(h.openTerminalCalls, ['A'], label); - assert.deepEqual(h.dormantIds(), ['B', 'x'], label); + assert.deepEqual(h.dormantIds(), ['A', 'B', 'x'], label); finishOpen(ok ? { ok: true } : { ok: false, error: 'boom' }); for (let j = 0; j < 5; j++) await settle(); await h.fns.persistWorkingSet(); - assert.deepEqual(h.dormantIds(), ok ? ['x'] : ['B', 'x'], label); - assert.deepEqual(h.savedIds(), ok ? ['A', 'x'] : ['B', 'x'], label); + assert.deepEqual(h.dormantIds(), ok ? ['x'] : ['A', 'B', 'x'], label); + assert.deepEqual(h.savedIds(), ok ? ['A', 'x'] : ['A', 'B', 'x'], label); assert.equal(h.row('A').classList.contains('dormant'), !ok, label); } finally { h.destroy(); } } @@ -960,6 +960,88 @@ test('lazy merged row: the session keys on a dormant member of a row with an ope } finally { h.destroy(); } }); +test('lazy: a saved session the user opens during the marking is marked while that open is in flight, and kept saved if it fails', async () => { + for (const ok of [false, true]) { + const label = ok ? 'x opens' : 'x fails'; + let finishOpen; + const h = setup({ openResult: () => new Promise((resolve) => { finishOpen = resolve; }) }); + try { + h.index(['a', 'x', 'b']); + let clicked = false; + h.window.api.getActiveSessions = async () => { + if (clicked) return []; + clicked = true; + h.open('x'); + for (let j = 0; j < 10; j++) await settle(); + return []; + }; + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['x'], label); + assert.deepEqual(h.dormantIds(), ['a', 'b', 'x'], label); + finishOpen(ok ? { ok: true } : { ok: false, error: 'boom' }); + for (let j = 0; j < 5; j++) await settle(); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.dormantIds(), ok ? ['a', 'b'] : ['a', 'b', 'x'], label); + assert.deepEqual(h.savedIds(), ['a', 'x', 'b'], label); + } finally { h.destroy(); } + } +}); + +test('lazy merged row: a second click, or the session keys on a dormant sibling, while a member is opening waits for that open and keeps the row\'s dormant members if it fails', async () => { + for (const second of ['click', 'keys']) { + for (const ok of [false, true]) { + const label = `${second}, ${ok ? 'B opens' : 'B fails'}`; + let finishOpen; + const h = setupMerged({ saved: ['B', 'C', 'x'], members: ['B', 'C'], openResult: () => new Promise((resolve) => { finishOpen = resolve; }) }); + try { + h.indexMerged(); + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.dormantIds(), ['B', 'C', 'x'], label); + h.row('A').click(); + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['B'], label); + if (second === 'click') { + h.row('A').click(); + } else { + h.run('activeSessionId = "B"'); + h.run('navigateSession(1)'); + } + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.dormantIds(), ['B', 'C', 'x'], `${label}: nothing is dropped before the open settles`); + finishOpen(ok ? { ok: true } : { ok: false, error: 'boom' }); + for (let j = 0; j < 5; j++) await settle(); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.openTerminalCalls, ['B'], `${label}: the second open starts nothing`); + assert.deepEqual(h.dormantIds(), ok ? ['x'] : ['B', 'C', 'x'], label); + assert.deepEqual(h.savedIds(), ok ? ['B', 'x'] : ['B', 'C', 'x'], label); + } finally { h.destroy(); } + } + } +}); + +test('lazy merged row: a click on the row during a member\'s reattach-only open resumes that member when no PTY is left', async () => { + let release; + const h = setupMerged({ + livePtys: ['B'], + openResult: (_id, options) => (options.reattachOnly ? new Promise((resolve) => { release = () => resolve({ ok: false, notLive: true }); }) : { ok: true }), + }); + try { + h.indexMerged(); + h.window.renderProjects(h.window.cachedProjects, true); + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 5; j++) await settle(); + h.row('A').click(); + for (let j = 0; j < 5; j++) await settle(); + release(); + await ticking; + for (let j = 0; j < 5; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['B', 'B']); + assert.equal(h.openTerminalOptions[1].reattachOnly, undefined, 'the click resumes it'); + assert.equal(h.window.openSessions.has('B'), true); + assert.deepEqual(h.dormantIds(), ['x']); + } finally { h.destroy(); } +}); + test('a save leaves out a session the restore started and the user stopped', async () => { const h = setup(); try { From e52abdf7901b6eeb00199b820fe8dbb39a442e92 Mon Sep 17 00:00:00 2001 From: abate Date: Wed, 14 Oct 2026 11:23:17 +0200 Subject: [PATCH 18/20] fix(restore): an open in flight is not counted as open by the marking or by a second open of its row The marking now marks a candidate whose open is still in flight, so a failed open leaves it saved; the open's own clearDormantRow drops it on success. openSessionNow waits for an in-flight open of the row's member before clearing the row, and resumes that member when the open it waited for was an automatic reattach that found no PTY. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 12 ++++++++---- public/app.js | 19 ++++++++++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 61dfcb5a2..f48ab807c 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited (one whose entry in `openSessions` is not `closed`), and takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member whose open has settled (open and not `closed` in `openSessions`, and not in `openingSessions`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant; a member whose open is still in flight (a click during the `getActiveSessions` await) does not count, so the candidate is marked, stays marked and saved if that open fails, and is dropped by the open's own `clearDormantRow` if it succeeds. That clear also drops the members of the row an earlier tick marked, which happens when they were indexed into the row after its open member opened; and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's dormant member; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. `openSessionNow`'s early return on an open member of the row waits first when that member's open is in flight (in `openingSessions`, and not this open), so a second click on the row or the session keys landing on a dormant sibling never clear the row before that open settles: if the member is open then, the row is cleared and the member shown; if not, the call returns and leaves the dormant members, unless the open it waited for was automatic and this one is not (a click during a member's reattach-only open that found no PTY), which then resumes that member. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited once that open has settled (its entry in `openSessions` is not `closed` and it is not in `openingSessions`); one whose open is still in flight is marked, so a failed open leaves it marked and saved, and the open's own `clearDormantRow` drops it on success. It takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member whose open has settled (open and not `closed` in `openSessions`, and not in `openingSessions`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant; a member whose open is still in flight (a click during the `getActiveSessions` await) does not count, so the candidate is marked, stays marked and saved if that open fails, and is dropped by the open's own `clearDormantRow` if it succeeds. That clear also drops the members of the row an earlier tick marked, which happens when they were indexed into the row after its open member opened; and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's first dormant id in `rowSessionIds` order, which for a click is the head first (#564), so the newest marked part; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index f5c8eaa01..e133586b3 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -161,7 +161,9 @@ is selected, the last active one included. id takes its place in the saved set; if that continuation is already open, it is shown and the marked session is simply dropped from the saved set. A continuation declined or failing to start stays marked and saved under the - new id, also when you open it before the marking has reached it. + new id, also when you open it before the marking has reached it. A saved + session you click while the marking is still running is marked too until it + is open, so it stays saved if it fails to start. - **Don't restore**, in a marked session's right-click menu, removes it from the saved set without starting it. So does deleting the session. - A marked session never clicked stays in the saved set, so the next start @@ -177,9 +179,11 @@ is selected, the last active one included. after another. The grid has no card for a marked session, so in the grid they skip it. - A saved session that is a member of a merged web-session row marks that - row. Clicking the row resumes the marked member, or shows the member already - open; either way the row is open, so every member of it is dropped from the - marked and saved sessions. **Don't restore** and deleting the row drop every + row. Clicking the row resumes its newest marked member, or shows the member + already open; either way the row is open, so every member of it is dropped + from the marked and saved sessions. A second click, or the session keys, + while a member is still starting wait for that start: if it fails, the row's + marked members stay marked and saved. **Don't restore** and deleting the row drop every marked member of it, and the session keys resume a marked member under its own id, or show the member already open. After a reload, a member whose row already has a running member is not marked, whatever its place in the saved diff --git a/public/app.js b/public/app.js index d7016e555..bd4e4a6b4 100644 --- a/public/app.js +++ b/public/app.js @@ -483,7 +483,7 @@ async function tickRestorePlanner() { if (item.fresh && !indexed) continue; const reattached = live.has(item.sessionId) && indexed && await openSession(indexed, undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; - const open = openSessions.get(item.sessionId); + const open = !openingSessions.has(item.sessionId) && openSessions.get(item.sessionId); const row = indexedRowOf(item.sessionId); const rowOpen = row && rowSessionIds(row).some(id => !openingSessions.has(id) && openSessions.has(id) && !openSessions.get(id).closed); if (!reattached && rowOpen) { @@ -1796,9 +1796,22 @@ async function openSessionNow(session, customOptions, { automatic = false, live, if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; if (!automatic) continuationRetryCancelled = true; if (open?.after) await open.after; - const openId = customOptions?.type === 'attach' + const currentOpenId = () => (customOptions?.type === 'attach' ? (openSessions.has(session.sessionId) && !openSessions.get(session.sessionId).closed ? session.sessionId : null) - : rowOpenId(session); + : rowOpenId(session)); + let openId = currentOpenId(); + const pending = openId && openingSessions.get(openId); + if (pending && pending !== open) { + // see .ai/contexts/session-cache.md ("Restore on click") + await pending.promise.catch(() => {}); + const settled = currentOpenId(); + if (!settled && (pending.byUser || automatic)) return; + if (!settled) { + session = { ...session, sessionId: openId, mergedMemberIds: [] }; + if (open) openingSessions.set(openId, open); + } + openId = settled; + } if (openId) { if (clearDormantRow(openId)) schedulePersistWorkingSet(); showSession(openId); From 665059bb4fac46832d28f9e6a478e9dacd2e4948 Mon Sep 17 00:00:00 2001 From: abate Date: Thu, 15 Oct 2026 08:57:13 +0200 Subject: [PATCH 19/20] test(restore): a running member whose reattach waited for the head's open is kept when it fails, and a session opened during the marking stays active Production rows carry headSessionId, so a member's reattach looks at the head's open; setupMerged can now set it. --- test/restore-lazy.test.js | 65 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/test/restore-lazy.test.js b/test/restore-lazy.test.js index 326cda034..1e591d3d3 100644 --- a/test/restore-lazy.test.js +++ b/test/restore-lazy.test.js @@ -714,16 +714,17 @@ test('lazy: a saved session with no transcript yet is neither started nor kept d } finally { h.destroy(); } }); -function setupMerged({ saved = ['B', 'x'], members = ['B'], slug = false, ...opts } = {}) { +function setupMerged({ saved = ['B', 'x'], members = ['B'], slug = false, head, ...opts } = {}) { const savedSet = saved.map((sessionId) => ({ sessionId, projectPath: PROJECT, active: sessionId === 'x' })); const h = setup({ savedSet, ...opts }); const project = h.window.cachedProjects[0]; const rowIds = new Set(['A', ...members]); + const headField = head ? { headSessionId: head } : {}; project.sessions = project.sessions.filter((s) => !rowIds.has(s.sessionId)); - project.sessions.push({ sessionId: 'A', name: 'web', modified: OLD, messageCount: 1, mergedMemberIds: members, ...(slug ? { slug: 'sl' } : {}) }); + project.sessions.push({ sessionId: 'A', name: 'web', modified: OLD, messageCount: 1, mergedMemberIds: members, ...headField, ...(slug ? { slug: 'sl' } : {}) }); if (slug) project.sessions.push({ sessionId: 'z', name: 'z', slug: 'sl', modified: OLD, messageCount: 1 }); h.indexMerged = () => { - h.window.sessionMap.set('A', { sessionId: 'A', projectPath: PROJECT, mergedMemberIds: members }); + h.window.sessionMap.set('A', { sessionId: 'A', projectPath: PROJECT, mergedMemberIds: members, ...headField }); h.window.sessionMap.set('x', { sessionId: 'x', projectPath: PROJECT }); h.window.indexLocalMergedMemberRows([{ sessions: [h.window.sessionMap.get('A')] }]); }; @@ -1042,6 +1043,64 @@ test('lazy merged row: a click on the row during a member\'s reattach-only open } finally { h.destroy(); } }); +test('lazy merged row: a running member whose reattach waited for the head\'s open is marked when that open fails, and the user\'s open stays active', async () => { + for (const ok of [false, true]) { + const label = ok ? 'B opens' : 'B fails'; + let finishOpen; + const h = setupMerged({ + saved: ['B', 'C', 'x'], members: ['B', 'C'], head: 'B', + openResult: (_id, options) => (options.reattachOnly ? { ok: true, reattached: true } : new Promise((resolve) => { finishOpen = resolve; })), + }); + try { + h.indexMerged(); + h.window.renderProjects(h.window.cachedProjects, true); + h.window.createTerminalEntry({ sessionId: 'n1', projectPath: PROJECT }); + h.run('activeSessionId = "n1"'); + let clicked = false; + h.window.api.getActiveSessions = async () => { + if (clicked) return []; + clicked = true; + h.row('A').click(); + for (let j = 0; j < 10; j++) await settle(); + return [{ sessionId: 'C', busy: false }]; + }; + const ticking = h.fns.tickRestorePlanner(); + for (let j = 0; j < 10; j++) await settle(); + assert.deepEqual(h.openTerminalCalls, ['B'], `${label}: the reattach of C waits for the head's open`); + finishOpen(ok ? { ok: true } : { ok: false, error: 'boom' }); + await ticking; + for (let j = 0; j < 5; j++) await settle(); + await h.fns.persistWorkingSet(); + assert.deepEqual(h.dormantIds(), ok ? ['x'] : ['B', 'C', 'x'], label); + assert.deepEqual(h.savedIds().filter((id) => id !== 'n1'), ok ? ['B', 'x'] : ['B', 'C', 'x'], label); + assert.equal(h.row('A').classList.contains('dormant'), !ok, label); + assert.equal(h.run('activeSessionId'), 'B', `${label}: the session the user opened stays active`); + } finally { h.destroy(); } + } +}); + +test('lazy: a session the user opens during the marking stays active after the tick reattaches another one', async () => { + const h = setup(); + try { + h.index(['a', 'x', 'b']); + h.window.createTerminalEntry({ sessionId: 'n1', projectPath: PROJECT }); + h.run('activeSessionId = "n1"'); + let clicked = false; + h.window.api.getActiveSessions = async () => { + if (clicked) return []; + clicked = true; + h.open('a'); + for (let j = 0; j < 10; j++) await settle(); + return [{ sessionId: 'b', busy: false }]; + }; + await h.fns.tickRestorePlanner(); + assert.deepEqual(h.openTerminalCalls, ['a', 'b']); + assert.equal(h.openTerminalOptions[1].reattachOnly, true); + assert.deepEqual(h.dormantIds(), ['x']); + assert.equal(h.run('activeSessionId'), 'a'); + } finally { h.destroy(); } +}); + test('a save leaves out a session the restore started and the user stopped', async () => { const h = setup(); try { From 9ede0f25e402240764b57225b1b8c26e5e924449 Mon Sep 17 00:00:00 2001 From: abate Date: Thu, 15 Oct 2026 09:47:02 +0200 Subject: [PATCH 20/20] fix(restore): the marking's reattaches leave the active session alone, and count only a session left open A reattach that returned early after another member's open failed was taken as done, dropping a running member from the saved set; and restoring the active session afterwards hid a session the user opened during the marking. --- .ai/contexts/session-cache.md | 2 +- docs/session-restore.md | 5 ++++- public/app.js | 14 +++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index f48ab807c..1855dfc85 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -145,7 +145,7 @@ A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiv - **Working-set restore retries until indexing is done, not once.** `populateCacheViaWorker` streams `sessionMap` one folder at a time on a cold start, so a saved working-set id can be missing for many ticks before it's genuinely indexed. `createRestorePlanner()` (`public/restore-plan.js`) is ticked from every `projects-changed` handler and from `updateIndexingBanner` on `payload.done`; it keeps returning `'wait'` until every saved id is indexed (as a row primary or via a row's `mergedMemberIds`) or indexing is over (then the rest is presumed deleted), restoring incrementally in `auto` mode and asking once (`askOnce: true`) in `ask` mode instead of re-prompting per tick. The end of indexing reaches the renderer on its own `indexing-finished` channel, sent at the end of **every** `populateCacheViaWorker` run, warm start included: `indexing-progress` is first-run only, so a warm start never told the planner that indexing was over and a saved session missing from the index left the "Finishing indexing" toast up for good. When the planner gives up (indexing over, or the tick cap) it returns the saved entries it never found as `unavailable`, and `tickRestorePlanner` names them in a "Not restored" notice. The end is also pullable (`get-indexing-state`, read once when the planner starts) because the startup scan can finish before the renderer listens, and `markRestoreIndexingDone` reloads the projects before the final tick because the last folders may not have reached `sessionMap` yet. See `test/session-restore-cold-cache.test.js`, `test/restore-unavailable.test.js`. A persist during that window keeps the entries not resolved yet (`pendingRestoreEntries`: the planner's pending ones, the ones awaiting the restore toast, and `restoreInFlight`, those handed to `runRestore` while their live-elsewhere check and open are awaited, even across two overlapping restores), otherwise any click or close would erase them from `openWorkingSet` before they are reached; see `test/restore-pending-persist.test.js`. A saved entry marked `fresh` (a local session with no transcript yet, see docs/session-restore.md) counts as indexed for the planner, and `runRestore` starts a new session in its project for it; see `test/clear-pending-persist.test.js`. -- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. `openSessionNow`'s early return on an open member of the row waits first when that member's open is in flight (in `openingSessions`, and not this open), so a second click on the row or the session keys landing on a dormant sibling never clear the row before that open settles: if the member is open then, the row is cleared and the member shown; if not, the call returns and leaves the dormant members, unless the open it waited for was automatic and this one is not (a click during a member's reattach-only open that found no PTY), which then resumes that member. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited once that open has settled (its entry in `openSessions` is not `closed` and it is not in `openingSessions`); one whose open is still in flight is marked, so a failed open leaves it marked and saved, and the open's own `clearDormantRow` drops it on success. It takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member whose open has settled (open and not `closed` in `openSessions`, and not in `openingSessions`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant; a member whose open is still in flight (a click during the `getActiveSessions` await) does not count, so the candidate is marked, stays marked and saved if that open fails, and is dropped by the open's own `clearDormantRow` if it succeeds. That clear also drops the members of the row an earlier tick marked, which happens when they were indexed into the row after its open member opened; and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's first dormant id in `rowSessionIds` order, which for a click is the head first (#564), so the newest marked part; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. +- **`restoreOnStartup: 'lazy'` (Restore on click) resumes nothing.** It ticks the same planner as `auto` (incremental, not `askOnce`), but `tickRestorePlanner` puts each candidate in `dormantWorkingSet` at its saved index instead of calling `runRestore`, and the sidebar marks those rows `.dormant` and keeps them visible. `persistWorkingSet` splices dormant entries back at their saved place, like skipped live-elsewhere ones, so a session never clicked survives further restarts. A session the user opens does not end the planner in this mode (`sessionOpenedOutsideRestore` is ignored): nothing is resumed behind the user's back, so there is no reason to stop marking the rest of a cold index. `openSession` drops the entry from `dormantWorkingSet` only after `openTerminal` succeeds, next to `skippedWorkingSetEntries.delete`: a resume refused by `guardResume` or a failed open leaves it dormant, and `persistWorkingSet` keeps a dormant or skipped entry whose open entry is `closed`, so neither drops it from the saved set. **Don't restore** in the session context menu (`dismissDormantSession`) is the way out of the set without spawning `claude`; the delete button calls it too. Entries the planner has not reached yet are kept by `pendingRestoreEntries`, so a persist during a cold index (a click, a close) does not drop them. `revealDormantSessions` (sidebar.js) un-collapses the project, worktree and slug groups holding a `.dormant` row, twice: on the tree `renderProjects` builds, before morphdom, so a group created after the marking is open, and on the live sidebar after marking, because the age-based collapse already applied on the first render and morphdom keeps an existing header's collapsed state. `navigateSession` (grid-view.js) includes dormant rows outside the grid and opens the one it lands on, except on a key auto-repeat (`e.repeat`), which cycles the open sessions only, so holding the key never resumes one dormant session after another; a failed open there is logged. `openSession` returns the open already in flight for a session (`openingSessions`, see `.ai/contexts/session-state.md`), so a double press or a double click starts one `openTerminal`; a click on a session whose reattach-only open is in flight is not joined to it but runs after it, so it resumes the session when no PTY was left. `openSessionNow`'s early return on an open member of the row waits first when that member's open is in flight (in `openingSessions`, and not this open), so a second click on the row or the session keys landing on a dormant sibling never clear the row before that open settles: if the member is open then, the row is cleared and the member shown; if not, the call returns and leaves the dormant members, unless the open it waited for was automatic and this one is not (a click during a member's reattach-only open that found no PTY), which then resumes that member. When a click resolves a dormant session to a continuation, `openSession` moves the `dormantWorkingSet` entry to the new id with the other saved-entry maps, so the old id is neither marked nor saved again; when that continuation is already open, the already-open branch of `openSessionNow` drops the moved entry before showing it. In lazy mode the remap also moves a candidate the marking has not reached from `restoreInFlight` to the continuation id (`open.continuedInFlight`), so the marking skips it and a persist meanwhile saves it under the new id; when that open settles (`settleContinuedInFlight`, from `openSession`'s `finally`) the entry leaves `restoreInFlight`, and becomes dormant under the new id unless the continuation is open, so a refused or failed continuation stays marked and saved. The marking also skips a candidate the user opened while it awaited once that open has settled (its entry in `openSessions` is not `closed` and it is not in `openingSessions`); one whose open is still in flight is marked, so a failed open leaves it marked and saved, and the open's own `clearDormantRow` drops it on success. It takes a candidate out of `restoreInFlight` once it marks it, so a persist during a later reattach does not write it twice. Reattach-only opens pass `continuationResolved: true`: they reattach the PTY saved under that id, never a continuation's. The marking's reattaches also pass `activate: false`, and `openSessionNow` honours `activate` (with `activeFrom`) on its early returns too, so they never change the active session: a session the user opens while the marking runs stays shown. The marking counts a candidate as reattached only when that id is then open and not `closed` in `openSessions`; any other outcome, including the `undefined` return when its reattach waited on another member's open (production rows carry `headSessionId`, so `rowOpenId` on a member looks at the head first) and that open failed, falls through to the clear-or-mark branches, so the candidate is cleared when its row is open and marked otherwise. When the awaited open succeeds the candidate's row is open and it is cleared; its own PTY, still running, is not reattached. While the lazy branch awaits `getActiveSessions` and the reattaches, its candidates sit in `restoreInFlight`, so a persist in that window (a dismiss, a delete, an exit) keeps the ones not handled yet. A renderer reload never spawns: `reopenActiveSessionAfterReload` and, for the other candidates, `tickRestorePlanner` open a session whose PTY `getActiveSessions` lists with `reattachOnly`, and `open-terminal` in main answers `{ ok: false, notLive: true }` instead of spawning when that PTY has exited since the snapshot; `openSession` then drops the terminal entry and returns `false`, and the session is marked dormant. A saved member of a merged web-session row (#551) has no `sessionMap` entry of its own: the planner offers it through `mergedMemberIds` (#558), and `indexedSessionById` gives the lazy marking, the reattach and `navigateSession` a session object under the member's own id. `indexedRowOf` maps a member to its whole row through `localMergedMemberRows` (rebuilt by `dedup`), falling back to a scan of `sessionMap` only for a remote row's member: the marking clears, instead of marking, a candidate whose row already has an open member whose open has settled (open and not `closed` in `openSessions`, and not in `openingSessions`), so after a reload a member reattached earlier or later in the saved order leaves no other member of its row dormant; a member whose open is still in flight (a click during the `getActiveSessions` await) does not count, so the candidate is marked, stays marked and saved if that open fails, and is dropped by the open's own `clearDormantRow` if it succeeds. That clear also drops the members of the row an earlier tick marked, which happens when they were indexed into the row after its open member opened; and `navigateSession` opens the whole row when it lands on a dormant member of a row with an open member, so `openSessionNow` shows that member and clears the row rather than resuming a second one. Row marks go through `sessionItemEl`, which finds a member's row: `syncDormantRow` sets `.dormant` from every id of the row, and `clearDormantRow`, run when a session opens or is shown already open (including `openSessionNow`'s early return on another open member), drops every dormant id of that row, since the row is then open. A row click (`openSession`, not automatic, no member open) is retargeted to the row's first dormant id in `rowSessionIds` order, which for a click is the head first (#564), so the newest marked part; **Don't restore**, Delete and the slug-group fold check use `rowSessionIds`. See `test/restore-lazy.test.js`, which loads the shipped functions and `sidebar.js`. - **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"). diff --git a/docs/session-restore.md b/docs/session-restore.md index e133586b3..c0e7acf88 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -190,7 +190,10 @@ is selected, the last active one included. set: the row is open. - Reloading the window starts nothing. A saved session whose process is still running is reattached, the selected one included; the others are marked. A - process that ends during the reload is not started again. + process that ends during the reload is not started again. Reattaching never + changes the selected session, so a session you open meanwhile stays shown. + A running member of a merged row whose reattach waited for another member you + opened meanwhile is marked if that open fails. Within one run of the app, going back to a session whose process still runs is a reattach: the terminal replays its buffered output onto the same process. diff --git a/public/app.js b/public/app.js index bd4e4a6b4..4003926d0 100644 --- a/public/app.js +++ b/public/app.js @@ -474,7 +474,6 @@ async function tickRestorePlanner() { if (restoreMode === 'lazy') { for (const item of candidates) restoreInFlight.set(item.sessionId, item); - const shown = activeSessionId; try { const live = new Set((await window.api.getActiveSessions().catch(() => [])).map(e => e.sessionId)); for (const [position, item] of candidates.entries()) { @@ -482,7 +481,8 @@ async function tickRestorePlanner() { const indexed = indexedSessionById(item.sessionId); if (item.fresh && !indexed) continue; const reattached = live.has(item.sessionId) && indexed - && await openSession(indexed, undefined, { automatic: true, reattachOnly: true, continuationResolved: true }) !== false; + && await openSession(indexed, undefined, { automatic: true, reattachOnly: true, continuationResolved: true, activate: false }) !== false + && openSessions.has(item.sessionId) && !openSessions.get(item.sessionId).closed; const open = !openingSessions.has(item.sessionId) && openSessions.get(item.sessionId); const row = indexedRowOf(item.sessionId); const rowOpen = row && rowSessionIds(row).some(id => !openingSessions.has(id) && openSessions.has(id) && !openSessions.get(id).closed); @@ -498,7 +498,6 @@ async function tickRestorePlanner() { } finally { for (const item of candidates) restoreInFlight.delete(item.sessionId); } - if (shown && activeSessionId !== shown && openSessions.has(shown)) showSession(shown); refreshSidebar(); revealDormantSessions(); return; @@ -1796,6 +1795,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, if (!restoringWorkingSet) sessionOpenedOutsideRestore = true; if (!automatic) continuationRetryCancelled = true; if (open?.after) await open.after; + const mayShow = () => activate && (activeFrom === undefined || activeSessionId === activeFrom); const currentOpenId = () => (customOptions?.type === 'attach' ? (openSessions.has(session.sessionId) && !openSessions.get(session.sessionId).closed ? session.sessionId : null) : rowOpenId(session)); @@ -1814,7 +1814,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, } if (openId) { if (clearDormantRow(openId)) schedulePersistWorkingSet(); - showSession(openId); + if (mayShow()) showSession(openId); return; } if (!continuationResolved && customOptions?.type !== 'attach' && (!openSessions.has(session.sessionId) || openSessions.get(session.sessionId).closed)) { @@ -1858,7 +1858,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, destroySession(sessionId); } else { if (clearDormantRow(sessionId)) schedulePersistWorkingSet(); - showSession(sessionId); + if (mayShow()) showSession(sessionId); return; } } @@ -1896,7 +1896,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.openError = result.error; entry.closed = true; - if (activate && (activeFrom === undefined || activeSessionId === activeFrom)) showSession(sessionId); + if (mayShow()) showSession(sessionId); return; } if (result.reattached) entry.attach = !!result.attach; @@ -1913,7 +1913,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, if (typeof setSessionMcpState === 'function') setSessionMcpState(sessionId, result.mcpState, result.mcpError); setSessionSandboxed(sessionId, result.sandbox); - if (activate && (activeFrom === undefined || activeSessionId === activeFrom)) showSession(sessionId); + if (mayShow()) showSession(sessionId); schedulePersistWorkingSet(); pollActiveSessions(); }