diff --git a/.ai/contexts/viewer-panel.md b/.ai/contexts/viewer-panel.md index 1d455ab32..856c865e8 100644 --- a/.ai/contexts/viewer-panel.md +++ b/.ai/contexts/viewer-panel.md @@ -164,7 +164,7 @@ Each of these reads and clears `pendingTouchedOpen` before `destroyCurrentTab`, Nothing ends the window with unsaved file-panel edits without the user saying so. The scope is the Touched editor in the slot (a tab with an `absolutePath`) and every `touchedStashes` entry, in every session of `filePanelState`; the Memory and Work Files panels, MCP diff tabs and Changes buffers are not covered. -- **Main** (`unsaved-guard.js`, one guard, `attach(win)` per window, `beforeQuit(event, win)` for the app). `main.js`'s `before-quit` handler calls `beforeQuit` first: it prevents the quit, asks the renderer, and calls `app.quit()` again on a yes, so the cleanup that follows (PTYs killed, MCP servers, watchers) runs only once the quit is confirmed and a Cancel leaves the app intact. This covers every `app.quit()` caller (☰ Quit, the last window closing). `updater-install` asks first (`confirmQuit`), because electron-updater's `quitAndInstall()` starts the installer before it calls `app.quit()`: a Cancel must leave the installer unstarted, and a yes pre-approves the quit that follows. A Windows `session-end` approves the quit, so logoff and shutdown never wait on the dialog; `query-session-end` approves it for 60 s (`LOGOFF_CANCEL_MS`), so a logoff that is cancelled does not skip the question on every later quit. A window close, a quit and an install share one question while it is open; one that joins a `'reload'` question sends `unsaved-check-reason` (`id`, `'quit'`), and the renderer acts on the reason it holds when the user answers. The window's own `close` event is held the same way for a plain window close. The question is `unsaved-check` (`id`, `'quit'` or `'reload'`); the renderer's `unsaved-check-result` (`id`, `proceed`) approves it. A `will-prevent-unload` (the renderer's `beforeunload` veto, which a reload hits) asks with `'reload'`; on a yes the next unload is allowed once (`allowNextUnload`, answered with `preventDefault()`, which in Electron means "unload anyway") and `webContents.reload()` is called. Every close asks the renderer, even with nothing dirty; a main-side dirty flag would save that round trip and is not built. +- **Main** (`unsaved-guard.js`, one guard, `attach(win)` per window, `beforeQuit(event, win)` for the app). `main.js`'s `before-quit` handler calls `beforeQuit` first: it prevents the quit, asks the renderer, and calls `app.quit()` again on a yes, so the cleanup that follows (PTYs killed, MCP servers, watchers) runs only once the quit is confirmed and a Cancel leaves the app intact. This covers every `app.quit()` caller (☰ Quit, the last window closing). `updater-install` asks first (`confirmQuit`), because electron-updater's `quitAndInstall()` starts the installer before it calls `app.quit()`: a Cancel must leave the installer unstarted, and a yes pre-approves the quit that follows. A Windows `session-end` approves the quit, so logoff and shutdown never wait on the dialog; `query-session-end` approves it for 60 s (`LOGOFF_CANCEL_MS`), so a logoff that is cancelled does not skip the question on every later quit. A window close, a quit and an install share one question while it is open; one that joins a `'reload'` question sends `unsaved-check-reason` (`id`, `'quit'`), and the renderer acts on the reason it holds when the user answers; a reload it has already approved then writes the app state at once, and main skips the reload of a question a quit joined. The window's own `close` event is held the same way for a plain window close. The question is `unsaved-check` (`id`, `'quit'` or `'reload'`); the renderer's `unsaved-check-result` (`id`, `proceed`) approves it. A `will-prevent-unload` (the renderer's `beforeunload` veto, which a reload hits) asks with `'reload'`; on a yes the next unload is allowed once (`allowNextUnload`, answered with `preventDefault()`, which in Electron means "unload anyway") and `webContents.reload()` is called. Every close asks the renderer, even with nothing dirty; a main-side dirty flag would save that round trip and is not built. - **Bounded.** The renderer acknowledges (`unsaved-check-ack`) as soon as it receives the check, before any dialog. The 2.5 s bound (`DEFAULT_TIMEOUT_MS`) covers only send to ack: no ack answers yes, so a hung renderer never keeps the app from quitting. The window is then closed the usual way (`win.close()`, or `app.quit()` for a quit or an update install), so a page that is only slow (still starting, or busy) closes as before, without the question, once it runs its `beforeunload`. A hung page never runs it, and that close would never finish: the guard destroys such a window when Electron reports it `unresponsive`, or when it is closed again, the guard's own `close()` or `app.quit()` aside. Electron reports a page `unresponsive` about 5 s after a close it has not completed, and cannot tell a hung page from a busy one: a page still busy about 5 s after that close, about 7.5 s after the click counting the 2.5 s bound, is destroyed, and its pending work is lost (the debounced working-set save among it). Only a page that recovers before then closes normally; a stall of 8–10 s that starts before the click (#526) does not. `e2e/hung-window.spec.js` measures it: the app exits about 7.5 s after the close it schedules. A page reported unresponsive earlier, and neither `responsive` nor acknowledging a check since, is destroyed when that close reaches it: right after `win.close()` for a window close, and for a quit or an update install only once the quit closes the window (its `close` event), so a quit still held by another `before-quit` handler (the ActivityWatch flush) is not cut short. A check the page acknowledged never destroys the window, however long the user takes. After the ack the guard waits for the answer without a limit, so a slow user or a slow save loses nothing, and answers yes only if the renderer process is gone (`render-process-gone`, `destroyed`) or a send fails. A late answer is ignored. - **Renderer** (`file-panel.js`). `askAboutUnsavedEdits()` lists the dirty tabs (`collectUnsavedFileTabs`) in the `#unsaved-edits-dialog` dialog, built on the add-project dialog's classes (already in the frameless no-drag list). Save writes every dirty entry: the editor in the slot through `handleChangesSave`, a stashed one through `saveFileForPanel(filePath, content, savedContent, {git, version})`, removed from its session once written. A save that fails (including a disk that moved) keeps the dialog open with the reason, and nothing is answered. Discard answers yes. Cancel and Escape answer no. A second request while the dialog is open gets the same dialog's answer. - **`beforeunload`.** The renderer vetoes an unload while an entry is dirty, unless `unloadApproved`, set for 10 s after the user answered yes. That is what stops a reload from the keyboard or devtools from slipping past, and what keeps the veto from asking a second time after an approved close. diff --git a/CHANGELOG.md b/CHANGELOG.md index f336910d8..a12cbd6f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - Closing the window or quitting no longer leaves Switchboard running when its window has stopped responding: the window closes as before, and once the system reports it not responding, about 5 seconds after the close, or once you close it again, it is closed anyway. A window still busy at that point is closed too, and its last unsaved changes to the list of open sessions are lost; one that recovers sooner closes normally. (#540) - Archiving or deleting a session no longer fails with "cannot tell whether a background job is running this session" while a background job that has not received its first prompt yet is live. (#553) - On Linux and Windows, `/clear` (or `/reset`, `/new`) in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session, and a session quit after `/clear` before its first prompt comes back on restore as a new session in the same project. On macOS the new conversation is still listed apart. (#477) +- Quitting or closing the window saves the open sessions right away, so the next start restores the sessions that were open, not an empty or out-of-date list. (#479) ## v0.0.92 — 2026-10-10 @@ -51,7 +52,6 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - When the Claude CLI reports a session's id in a different letter case, the subagents that session started or finished during a turn now show up when the turn ends. (#487) - A session that moves into a worktree while it runs stays in the sidebar, under its project, instead of disappearing while its terminal keeps running. A running session also stays listed when its transcript leaves its folder for any other reason. (#485) - Worktree folders from another host, or whose repository is not listed, show in the sidebar instead of being nested under the wrong folder or not shown at all. The worktrees of a repository hidden with Hide Project stay hidden. (#473) -- Quitting or closing the window saves the open sessions right away, so the next start restores the sessions that were open, not an empty or out-of-date list. (#479) - Remote triggers refuse commands containing invisible format characters, default-ignorable characters or braille blanks, including joined emoji, emoji with variation selectors (such as hearts), soft hyphens and right-to-left marks. Fullwidth slash, exclamation and number-sign prefixes are refused too. (#440) ## v0.0.89 — 2026-10-04 diff --git a/docs/session-restore.md b/docs/session-restore.md index 33fc4df1b..7467daae6 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -55,7 +55,10 @@ window open), and stops saving it for the next 10 s. Main stops sending (`appQuitting`), so the shutdown cannot record them as closed. A reload is not an exit and does neither. A quit or close that arrives while a reload's question is still open joins it, and main tells the renderer the question is -now an exit (`unsaved-check-reason`), so the answer still waits for the write. +now an exit (`unsaved-check-reason`), so the answer still waits for the write, +and the page is not reloaded before the quit. If the page had already answered +the reload when the upgrade reaches it, it writes the set then, best effort as +for a logoff below. The write keeps the saved sessions that are not open yet: those the index has not reached, those offered by the Restore prompt and not answered, and those diff --git a/public/app.js b/public/app.js index f23c78e59..df9c4a5b2 100644 --- a/public/app.js +++ b/public/app.js @@ -203,14 +203,15 @@ function persistWorkingSet({ final = false } = {}) { ...(fresh ? { fresh: true } : {}), }); } - const pending = pendingRestoreEntries(); - const heldActive = pending.find(({ item }) => item.active && !openSessions.has(item.sessionId)); + const notStarted = pendingRestoreEntries().filter(({ item }) => !openSessions.has(item.sessionId)); + const heldActive = notStarted.find(({ item }) => item.active); if (heldActive) for (const e of set) e.active = false; - const held = [...skippedWorkingSetEntries.values(), ...pending] + const held = [...skippedWorkingSetEntries.values(), ...notStarted] .filter(({ item, keepAttached }) => !openSessions.has(item.sessionId) || (keepAttached && openSessions.get(item.sessionId).attach)) .sort((a, b) => a.index - b.index); for (const { item, index, keepAttached } of held) { - set.splice(Math.min(index, set.length), 0, { sessionId: item.sessionId, projectPath: item.projectPath, active: heldActive ? heldActive.item === item : !!keepAttached && item.sessionId === activeSessionId, ...(item.fresh ? { fresh: true } : {}) }); + const active = heldActive ? heldActive.item === item : !!keepAttached && item.sessionId === activeSessionId; + set.splice(Math.min(index, set.length), 0, { sessionId: item.sessionId, projectPath: item.projectPath, active, ...(item.fresh ? { fresh: true } : {}) }); } global.openWorkingSet = set; await window.api.setSetting('global', global); diff --git a/public/file-panel.js b/public/file-panel.js index 1ed00ad19..a91a9de00 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -151,9 +151,13 @@ function initFilePanel() { }); if (window.api.onUnsavedCheck) { const unsavedCheckReasons = new Map(); + const answeredReloads = new Set(); if (window.api.onUnsavedCheckReason) { window.api.onUnsavedCheckReason((id, reason) => { if (unsavedCheckReasons.has(id)) unsavedCheckReasons.set(id, reason); + else if (answeredReloads.delete(id) && typeof window.flushStateForExit === 'function') { + window.flushStateForExit().catch((err) => console.error('[exit-flush]', err)); + } }); } window.api.onUnsavedCheck(async (id, reason) => { @@ -168,6 +172,7 @@ function initFilePanel() { const bound = new Promise((resolve) => setTimeout(resolve, EXIT_FLUSH_BOUND_MS)); try { await Promise.race([window.flushStateForExit(), bound]); } catch (err) { console.error('[exit-flush]', err); } } + if (proceed && finalReason === 'reload') answeredReloads.add(id); window.api.unsavedCheckResult(id, proceed); }); } diff --git a/test/dom-file-panel-unsaved-guard.test.js b/test/dom-file-panel-unsaved-guard.test.js index c381a9d96..e1ad0d09f 100644 --- a/test/dom-file-panel-unsaved-guard.test.js +++ b/test/dom-file-panel-unsaved-guard.test.js @@ -286,6 +286,33 @@ test('a reload check that a quit joins before it is answered flushes once', asyn } finally { ctx.destroy(); } }); +test('a quit that joins a reload check the page has already answered still writes the app state', async () => { + const ctx = setup(); + try { + let flushed = 0; + ctx.window.flushStateForExit = () => { flushed++; return Promise.resolve(); }; + ctx.calls.check(6, 'reload'); + await flush(); + assert.deepEqual(ctx.calls.answers, [{ id: 6, proceed: true }]); + ctx.calls.reason(6, 'quit'); + await flush(); + assert.equal(flushed, 1); + ctx.calls.reason(6, 'quit'); + ctx.calls.reason(99, 'quit'); + await flush(); + assert.equal(flushed, 1, 'once, and only for a check this page answered yes'); + + await dirtyTab(ctx, 's1', A); + ctx.calls.check(7, 'reload'); + await flush(); + button(ctx, 'unsaved-cancel').click(); + await flush(); + ctx.calls.reason(7, 'quit'); + await flush(); + assert.equal(flushed, 1, 'a reload answered no is not written for'); + } finally { ctx.destroy(); } +}); + test('a cancelled close does not flush', async () => { const ctx = setup(); try { diff --git a/test/exit-flush.test.js b/test/exit-flush.test.js index 545f87e3d..b30875463 100644 --- a/test/exit-flush.test.js +++ b/test/exit-flush.test.js @@ -195,3 +195,26 @@ test('a Windows session end flushes the app state', async () => { await t.run('_persistChain'); assert.deepEqual(t.writes, [['a']]); }); + +test('a not-started entry holding the saved active marker keeps it over a reattached session shown meanwhile', async () => { + const t = setup(); + t.saved(['a', 'b', 'c'], 'c'); + t.run('restoreInFlight.set("c", savedSet[2]); skippedWorkingSetEntries.set("b", { item: savedSet[1], index: 1, keepAttached: true })'); + t.open('a'); + t.open('b'); + t.context.openSessions.get('b').attach = true; + t.context.activeSessionId = 'b'; + await t.flushStateForExit(); + assert.deepEqual(t.store.global.openWorkingSet.map((e) => [e.sessionId, e.active]), [['a', false], ['b', false], ['c', true]]); +}); + +test('main stops reporting session exits once the quit starts killing them', () => { + const main = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8').replace(/\r\n/g, '\n'); + const onExit = main.slice(main.indexOf('ptyProcess.onExit('), main.indexOf('\n });', main.indexOf('ptyProcess.onExit('))); + assert.match(onExit, /if \(mainWindow && !mainWindow\.isDestroyed\(\) && !appQuitting\) \{\n\s*mainWindow\.webContents\.send\('process-exited'/); + const quitStart = main.indexOf("app.on('before-quit'"); + const quit = main.slice(quitStart, main.indexOf('\n});', quitStart)); + const flag = quit.indexOf('appQuitting = true;'); + assert.notEqual(flag, -1, 'before-quit raises the flag'); + assert.ok(flag < quit.indexOf('killPty('), 'the flag is up before the first kill'); +}); diff --git a/test/unsaved-guard.test.js b/test/unsaved-guard.test.js index 7668c91a3..fbf65a17b 100644 --- a/test/unsaved-guard.test.js +++ b/test/unsaved-guard.test.js @@ -283,6 +283,18 @@ test('a quit that joins an unanswered reload check tells the renderer it is now assert.equal(quits.length, 1); }); +test('a reload check that a quit joined quits without reloading first', async () => { + const quits = []; + const t = setup({ quit: () => quits.push(1) }); + t.wc.emit('will-prevent-unload', t.closeEvent()); + const id = t.sent[0].args[0]; + t.guard.beforeQuit(t.closeEvent(), t.win); + t.answer(id, true); + await tick(); + assert.equal(t.wc.reloads, 0); + assert.equal(quits.length, 1); +}); + test('a reload joining a quit check leaves it a quit', () => { const t = setup(); t.win.emit('close', t.closeEvent()); diff --git a/unsaved-guard.js b/unsaved-guard.js index 3dbbc846d..639ec7416 100644 --- a/unsaved-guard.js +++ b/unsaved-guard.js @@ -35,7 +35,7 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan if (inflight) { if (inflight.reason === 'reload') { inflight.reason = reason; - try { win.webContents.send('unsaved-check-reason', inflight.id, reason); } catch { /* the check ends without it */ } + try { win.webContents.send('unsaved-check-reason', inflight.id, reason); } catch {} } return inflight.asked; } @@ -125,7 +125,7 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan }, logoffCancelMs); const wc = win.webContents; if (win.isDestroyed() || !wc || wc.isDestroyed()) return; - try { wc.send('exit-flush'); } catch { /* the window is going anyway */ } + try { wc.send('exit-flush'); } catch {} }; win.on('query-session-end', () => endSession(false)); win.on('session-end', () => endSession(true)); @@ -173,8 +173,11 @@ function createUnsavedGuard({ ipcMain, timeoutMs = DEFAULT_TIMEOUT_MS, logoffCan } if (reloading) return; reloading = true; - ask(win, 'reload').then((proceed) => { + const asked = ask(win, 'reload'); + const check = inflight; + asked.then((proceed) => { reloading = false; + if (check && check.reason !== 'reload') return; if (!proceed || win.isDestroyed() || win.webContents.isDestroyed()) return; allowNextUnload = true; win.webContents.reload();