Skip to content
Merged
4 changes: 2 additions & 2 deletions .ai/contexts/viewer-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ 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; a reload it has already approved then writes the app state at once. The renderer answers every upgrade with `unsaved-check-reason-done` (`id`): at once, unless it is writing for an approved reload, then once that write ends; main holds the joined quit until it, bounded by the check timeout and released at once if the renderer process is gone (`render-process-gone`, `destroyed`), since the answer may have left before the upgrade arrived. Until then the joined question stays the one in flight, so a close, quit or install arriving meanwhile joins it instead of asking again. 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.
- **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. The window's own `close` event is held the same way for a plain window close. The question is `unsaved-check` (`id`, `'close'` for the window's own close, `'quit'` or `'reload'`; `'close'` also gets a "Close Switchboard?" confirm, see [window-frame](window-frame.md), "Closing the window"); a request that joins a pending question with a stronger reason upgrades it (`unsaved-check-reason`, `'reload'` < `'close'` < `'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. Each upgrade carries a token (`unsaved-check-reason`, `id`, reason, token), and the renderer answers it with `unsaved-check-reason-done` (`id`, token): at once, unless it is writing for an approved reload, then once that write ends; main holds the joined request until that upgrade's own report, bounded by the check timeout, released at once if the renderer process is gone (`render-process-gone`, `destroyed`), and not at all when the check ended unanswered, since the answer may have left before the upgrade arrived. Until the last upgrade joined to it has reported, the joined question stays the one in flight, so a close, quit or install arriving meanwhile joins it instead of asking again. Main skips the reload of a question a quit or a close joined. 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, and only for `'quit'` and `'reload'`: no ack answers yes, so a hung renderer never keeps the app from quitting. A `'close'` check has no such bound: a busy page shows the close question once it is free, and a hung one is ended when Electron reports it unresponsive or the user closes it again (see [window-frame](window-frame.md), "A window that stops answering"). After an unanswered quit or update install the window is closed the usual way (`app.quit()`), so a page that is only slow 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 quit counting the 2.5 s bound, is destroyed, and its pending work is lost (the debounced working-set save among it); a page that recovers before then closes normally. A page reported unresponsive earlier, and neither `responsive` nor acknowledging a check since, is destroyed 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`), a send fails, or a repeated close finds the page not answering (window-frame, "A window that stops answering"). The bounds pause while the page shows a native dialog ("A native dialog pauses the bounds" there). 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.
- **Closing a session** does not drop its file panel: `destroySession` leaves `filePanelState` alone, so the tab and the stashes, dirty or not, are still there when the session is opened again, and the quit check still sees them. Deleting a session leaves its state in memory too, so its edits are asked about at quit rather than lost.
Expand Down
123 changes: 123 additions & 0 deletions .ai/contexts/window-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,126 @@ controls back, and the restored window keeps its saved bounds.
If the drag region were unreachable, the window manager's own bindings still
move and resize the window: `Super`+drag on GNOME, `Alt`+`F7`/`Alt`+`F8`, or
`Alt`+`Space` then Move/Size on Windows.

## Closing the window

The close button sits a few pixels from the strip's own controls and the
terminal's top-right corner, so a stray click there ended the app and every
session in it. A **window close** (the close button, `Alt`+`F4`, the window
manager's own close) now asks "Close Switchboard?" first; Cancel or `Escape`
leaves everything running. On macOS closing the window does not quit (the app
stays in the Dock), but the window's `closed` handler still stops every
session, so there the question reads "Close the window?" and says the app
stays in the Dock. The question is the in-page choice dialog
(`confirmWindowClose` in `file-panel.js`), not a native `confirm()`: Cancel
has the focus, so `Enter` keeps the app, and the page keeps running while it
is shown, which the probe below relies on. Only one choice dialog shows at a
time, so the close question is asked with `replace: true`: another one that is
open (the Archive folder dialog, say) is cancelled first, and a close is never
answered with a silent Cancel because something else was on screen.

It rides on the unsaved-edits guard (`unsaved-guard.js`, see
[viewer-panel](viewer-panel.md), "Unsaved edits on quit, reload and close"):
the window's `close` event asks the renderer with the reason `'close'`, while
`before-quit` and the updater ask with `'quit'`. The renderer
(`file-panel.js`, the `onUnsavedCheck` handler) adds the confirm only for
`'close'`, and only when the unsaved-edits dialog was not shown: answering
that dialog with Save or Discard is already a decision to close. A **quit**
(☰ → Quit, which reads Exit on Windows and has `Ctrl`+`Q` only on Linux and
`Cmd`+`Q` on macOS; an update install; `SIGTERM`) is not asked about again. A
Windows logoff or shutdown approves the quit before the question
(`query-session-end`).

A check for `'close'` has no time bound before the acknowledgement: a page that
is busy when the close arrives (stalled on a tab reveal) shows the question once
it is free, rather than closing without it. A page still starting has no
listener yet (`onUnsavedCheck` is registered by `initFilePanel`), so the check
it missed is sent again: registering the listener sends `unsaved-ready`, and
main sends the pending check again, with the reason it holds now, if it is not
acknowledged yet and that page is the one asked (the renderer ignores an id it
already has). A `'quit'` check keeps the 2.5 s bound, so a quit never waits for
ever on the page.

A quit that arrives while the close question is pending (`SIGTERM`, a Linux or
macOS logout, an update install) joins the same check, and the guard upgrades
its reason: main sends `unsaved-check-reason` (`id`, `'quit'`), the
renderer closes the close question as a yes (or skips it if it is not shown
yet), and a check not acknowledged yet gets the quit's 2.5 s bound. The
unsaved-edits dialog stays: Cancel there still keeps the app, as for any quit.
A reason is only ever upgraded (`'reload'` < `'close'` < `'quit'`): a close
joining a reload question becomes a close, and a close joining a quit stays a
quit.

### A window that stops answering

A hung page would hold a close for good: nothing bounds a `'close'` check
before the acknowledgement, and after it the guard waits for the answer
without a limit, since a person may take their time. So the guard ends the
check as a yes and destroys the window (`win.destroy()`; a `close()` would wait
for the hung page's `beforeunload`) in two cases only:

- **Electron reports the page `unresponsive`** while the check is not
acknowledged, or while a ping (below) waits for its answer. An acknowledged
check with no ping out ignores it: the user may be reading the question.
Electron does not report a page that hung before the close, since the guard
holds the close and nothing else is waiting on the page: in CI
(`e2e/hung-window.spec.js` with a single close, run 38070586412) the window
was still there 60 s later. So in practice a hung page is ended by the next
case.
- **The user closes the window again.** A close that arrives while the check
is still not acknowledged 2.5 s after it was sent (`DEFAULT_TIMEOUT_MS`;
`entry.overdue`) ends it; one that comes sooner, a double click on the close
button, does nothing. Once the page has acknowledged, a close that arrives
while the check is pending sends `unsaved-ping`; the preload answers it with
`unsaved-pong` on the renderer's main thread (the listener is added with
`onUnsavedCheck`), so a page that hangs cannot. Only a pong from the probed
window's own `webContents` counts, and it marks the page responsive again.
A ping that has gone unanswered for 1.5 s (`DEFAULT_PROBE_MS`) is overdue,
and the next close ends the check. Neither delay destroys anything by
itself: a page busy for 10 s survives unless the user closes it again. A
ping that cannot be sent ends the check at once.

Only the window's own `close` event counts as a repeated close: a quit
(`before-quit`, `SIGTERM`) or an update install joining the check never pings,
so neither can be taken for the user insisting.

A check the page never acknowledged for a quit ends at the 2.5 s bound; the
window is then closed the usual way and destroyed only once Electron reports
it unresponsive or it is closed again ([viewer-panel](viewer-panel.md),
"Bounded"). Electron reports a page about 5 s after a close it has not
completed and cannot tell a busy page from a hung one, so a page still busy
about 7.5 s after the quit (the 2.5 s bound, then those 5 s) is destroyed, and
its debounced working-set save is lost. The force waits for the quit to close
the window: a quit still held by another `before-quit` handler (the
ActivityWatch flush) is not cut short. `taskkill` without `/F` sends a close and gets the question like
any close; `taskkill /F` (and an installer that escalates to it) ends the
process without the quit cleanup, as it did before.

Cancel on the close question or on the unsaved-edits dialog gives the focus
back to the element that had it (`returnFocus`), usually the terminal.

### A native dialog pauses the bounds

A native `alert()`, `confirm()` or `prompt()` blocks the page's main thread
until it is answered, and the preload's listeners run on that thread, so a page
showing one can neither acknowledge a check nor answer a ping. The check's own
path shows none (the close question and the unsaved-edits dialog are in-page,
and their saves report failures in the dialog), but other code does: the
sidebar's delete and hide confirms, the resume guard, the viewer's overwrite
and delete confirms.

So the page reports them. `reportNativeDialogs` (`file-panel.js`, run by
`initFilePanel`) wraps `window.alert`, `window.confirm` and `window.prompt`
and sends `unsaved-dialog` `true` before the call and `false` after it
(`window.api.unsavedDialog`); a bare `confirm(…)` resolves to the wrapper too.
The `true` is sent before the thread blocks, so main has it before the dialog
shows. Main keeps the reporting `webContents` in a set. While it is there, a
repeated close sends no ping and ends nothing, `unresponsive` ends nothing,
and a quit check's 2.5 s bound is replaced by a 5-minute ceiling
(`DEFAULT_DIALOG_CEILING_MS`): the user can still answer the dialog, and a
quit (`SIGTERM`, a Linux or macOS logout) cannot wait for ever. When the
dialog closes, the bound that applies starts again from zero
(`entry.arm()`), and the page handles the queued check within milliseconds. A
crash or a navigation of that page clears its entry, so a dialog that never
reported its end cannot pause the bounds for good. A Windows logoff is not
affected, it approves the quit first.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

### Changed
- Markdown previews now show a file's frontmatter as a readable header above its content, including its name, description and metadata. (#593)
- Closing the window (the close button, `Alt`+`F4`) asks for confirmation first, with Cancel selected; a window that is busy at that moment asks once it is free. Quitting from the ☰ menu (Quit, or Exit on Windows) does not ask again, and a quit from outside (a logout, an update install) is not stopped by the question. On macOS it asks "Close the window?": Switchboard stays in the Dock, but its sessions stop. If the window has stopped responding, a single close waits, and closing it again a few seconds later ends it; never while one of its own confirmation boxes waits for an answer. A window closed while it is still starting asks once it has loaded. (#478)

### Fixed
- On Windows and Linux, automation triggers follow a running conversation when its id changes, except in WSL or the Linux sandbox. (#579)
Expand Down
Loading
Loading