ADR 0090: User-Configurable Close Behavior with Close-to-Tray
- Status: Accepted for implementation
- Date: 2026-08-12
- Deciders: PI-Desktop core
- Related: D230, D256, ADR 0021, ADR 0025, ADR 0078, ADR 0123
Context
On Windows/Linux, closing the main window calls app.quit() (window-all-closed), so the app exits and its taskbar entry disappears. Minimizing already hides the window into the tray that D216 (ADR 0078) keeps resident on every platform, but users with long-running chats expect closing the window to keep the app available too. Different users want different defaults, and a fixed close-to-tray behavior would surprise users who expect close to quit.
The app has no close interception and no user-facing choice for this lifecycle decision. macOS is out of scope: the native Dock lifecycle (close keeps the app in the Dock, activate recreates the window) already matches the desired behavior.
Decision
- Windows/Linux close behavior becomes a persisted, user-configurable preference with three values, of which two are ever settable:
ask: the transient unset state — the first close prompts once. After a choice is made it is remembered permanently and cannot be reverted to prompting (closeBehavior/setrejectsask).tray: closing the window hides it under the resident D216 system-tray icon and keeps the app running; the tray menu restores the window or quits the app.quit: legacy behavior — closing the window exits the app.
- The first close with an unset preference shows a native modal dialog (main process) with Cancel / Close to tray / Quit. Picking a non-cancel option persists it forever; Cancel keeps the window open and leaves the preference unset (so the next close prompts again).
- The preference is stored by Electron main in
<data>/close-behavior.json(same ownership pattern aswindow-state.json), NOT in host-core settings: it is app-shell lifecycle state, read and written only by the main process, and needs no host RPC or schema change. - Two additive IPC channels expose it to the renderer:
pi-desktop/window/closeBehavior/get(returns{ behavior, supported }) andpi-desktop/window/closeBehavior/set, which accepts onlytrayandquit(askand unknown values fail withINVALID_ARGUMENT).supportedisfalseon macOS, where the Settings row is hidden andsetitself fails withINVALID_ARGUMENT— the renderer is not the only caller, so the guard lives in main. - Settings (General tab) renders a two-option radio segment — Close to tray / Quit app — for Windows/Linux only; an unset preference shows no selection. Changing it applies immediately. The tray icon is not reconciled: D216 owns it and it stays resident under either choice, because minimize-to-tray needs it whatever close does.
- The close handler intercepts every non-macOS
closethat is not already an approved quit;before-quit(quitting) and macOS closes always fall through, so an explicit quit, the tray Quit item, and the automated boot probe are unaffected. Underquitthe handler callsapp.quit()itself rather than leaning onwindow-all-closed, andwindow-all-closedkeys off the preference — it stays silent only undertray, keeping the app alive when the window is tray-hidden or destroyed unexpectedly, and quits otherwise even though the D216 tray is present. Atrayclose whose tray icon does not exist falls back to a real quit rather than hiding the window with no way back. - The bounds watchdog (
ensureStableBounds) skips minimized and hidden windows, so minimize always stays minimized and a tray-hidden window is never force-restored by the Stage-Manager shelf recovery. - Known limitation: on Windows system shutdown/logoff, the OS may deliver a
closethat is intercepted while the preference isaskortray. Windows force-terminates the session after its shutdown timeout, so no data is lost, but shutdown is not accelerated by the app.
Alternatives considered
- Store the preference in host-core settings (
AppSettings): rejected because it would add a Rust schema field and host RPC surface for pure shell lifecycle state that only Electron main consumes. - Renderer-drawn first-close dialog: rejected because the window is closing; a native modal keeps the decision in the process that owns the close lifecycle and works before the renderer has mounted.
- Always close-to-tray without a choice: rejected — it changes the meaning of the close button for users who expect exit.
- Tray icon reconciled with the preference: rejected; D216 keeps one tray icon resident on every platform for minimize-to-tray, so destroying it when close behavior is
quitwould break an unrelated feature. Close behavior decides what a close does, not whether the tray exists.
Consequences
- Windows/Linux explicit minimize uses the normal taskbar transition; a close choice of
traystill hides the window under the resident D216 tray icon. macOS native minimize remains tray-resident under D216. - Close on Windows/Linux either hides to tray or quits, per user choice, remembered across launches and changeable in Settings.
- The tray menu and the first-close dialog reuse the existing
@pi-desktop/i18ncatalogs (English and Simplified Chinese). - No host protocol, storage schema, or macOS behavior changes.