diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index af00082bfb..ed1a6ce030 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -15,7 +15,7 @@ import { import { dirname, join, resolve } from "node:path"; import { execFileSync } from "node:child_process"; import { homedir } from "node:os"; -import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { listInstalledFonts } from "./system-fonts"; import { APP_ID, @@ -49,6 +49,7 @@ import { type AskToolResolution, type AppMenuCommand, type AppNotification, + type CloseBehavior, type CommandShellCatalog, type CommandShellId, type GlobalPermissionMode, @@ -91,6 +92,7 @@ import type { PluginNotificationPermission, } from "@pi-desktop/plugin-sdk"; import { resolvePluginLocalizedString } from "@pi-desktop/plugin-sdk"; + import { HostProcess } from "./host-process"; import { PersistenceOutbox } from "./persistence-outbox"; import { AgentSidecar } from "./agent-sidecar"; @@ -183,6 +185,13 @@ let sidecar: AgentSidecar | null = null; let quitting = false; let shutdownComplete = false; let shutdownPromise: Promise | null = null; +// Windows/Linux system-tray presence. When set, closing the main window +// hides it to the tray instead of quitting the app (close-to-tray). +// User-chosen close behavior on Windows/Linux; "ask" prompts on first close. +let closeBehavior: CloseBehavior = "ask"; +let closePromptOpen = false; +let allowWindowClose = false; + let pluginNotificationPermission: PluginNotificationPermission = "unknown"; const pluginNativeNotifications = new Set(); const PLUGIN_NOTIFICATION_TIMEOUT_MS = 2_000; @@ -258,6 +267,7 @@ const pluginPanels = new PluginPanelHost( data: { api: "panel.egress", ok: false, url, ts: Date.now() }, }); }, + ); const plugins = new PluginRuntime({ getWorkspacePath: () => { @@ -960,13 +970,51 @@ function updateTrayMenu(locale = app.getLocale()) { const labels = resolveLocale(locale) === "zh-CN" ? zhCN.tray : en.tray; tray.setContextMenu( Menu.buildFromTemplate([ - { label: labels.show, click: restoreMainWindow }, + { label: labels.open, click: restoreMainWindow }, { type: "separator" }, { label: labels.quit, click: () => app.quit() }, ]), ); } +/** Restores a tray-hidden or minimized main window, or recreates it. */ +function revealMainWindow() { + const window = mainWindow; + if (!window || window.isDestroyed()) { + void ensureWindow(); + return; + } + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); +} + +/** + * Windows/Linux tray icon for close-to-tray: closing the main window hides + * it to the tray (the app keeps running in the background) and the tray + * restores it. macOS keeps the native Dock lifecycle and gets no tray. + */ +function createTrayIcon() { + if (process.platform === "darwin" || tray) return; + const iconPath = join(app.getAppPath(), "build", "icon.png"); + const icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }); + if (icon.isEmpty()) { + logger.app("lifecycle", "warn", "tray icon missing", { data: { iconPath } }); + return; + } + const labels = resolveLocale(app.getLocale()) === "zh-CN" ? zhCN : en; + tray = new Tray(icon); + tray.setToolTip(APP_NAME); + tray.on("click", revealMainWindow); + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: labels.tray.open, click: revealMainWindow }, + { type: "separator" }, + { label: labels.tray.quit, click: () => app.quit() }, + ]), + ); +} + function createTray() { if (tray) return; const iconPath = trayIconPath(); @@ -997,6 +1045,7 @@ function createTray() { updateTrayMenu(); } + function sendToRenderer(channel: string, payload: unknown) { if (!IPC_WHITELIST.has(channel)) return; const window = mainWindow; @@ -1345,6 +1394,61 @@ function writeWindowState(state: WindowState) { } } +function closeBehaviorPath() { + return join(dataDir, "close-behavior.json"); +} + +function readCloseBehavior(): CloseBehavior | null { + try { + const raw = JSON.parse(readFileSync(closeBehaviorPath(), "utf8")); + return raw === "ask" || raw === "tray" || raw === "quit" ? raw : null; + } catch { + return null; + } +} + +function writeCloseBehavior(behavior: CloseBehavior) { + try { + mkdirSync(dataDir, { recursive: true }); + writeFileSync(closeBehaviorPath(), JSON.stringify(behavior), "utf8"); + } catch { + // best-effort persistence + } +} + +/** Applies a close-behavior choice and reconciles the tray icon with it. */ +function applyCloseBehavior(next: CloseBehavior) { + closeBehavior = next; + writeCloseBehavior(next); + if (next === "tray") createTrayIcon(); + else if (tray) { + tray.destroy(); + tray = null; + } +} + +/** + * First-close prompt on Windows/Linux: asks whether closing the window + * should hide the app to the tray or exit it. The choice is persisted and + * can be changed later in Settings. Returns null when the user cancels. + */ +async function askCloseBehavior( + window: BrowserWindow, +): Promise<"tray" | "quit" | null> { + const labels = resolveLocale(app.getLocale()) === "zh-CN" ? zhCN : en; + const { response } = await dialog.showMessageBox(window, { + type: "question", + title: labels.tray.askTitle, + message: labels.tray.askTitle, + detail: labels.tray.askBody, + buttons: [labels.common.cancel, labels.tray.closeToTray, labels.tray.quit], + defaultId: 1, + cancelId: 0, + noLink: true, + }); + return response === 1 ? "tray" : response === 2 ? "quit" : null; +} + function workPanelMinimumWindowWidth() { return WINDOW_MIN_WIDTH + workPanelReservation.width; } @@ -1851,7 +1955,17 @@ async function createWindow() { }; const ensureStableBounds = (force = false) => { - if (!isLiveWindow() || boundsGuard || captureViewportOverride) return; + if ( + !isLiveWindow() || + boundsGuard || + captureViewportOverride || + // Never fight the user's own state: a minimized window stays + // minimized and a tray-hidden window stays hidden. + window.isMinimized() || + !window.isVisible() + ) { + return; + } const electronBounds = window.getBounds(); const cg = readCgBounds(); if (!cg && cgHelperAvailable) missingCgStreak += 1; @@ -1953,12 +2067,45 @@ async function createWindow() { }; window.on("resize", scheduleStateSave); window.on("move", scheduleStateSave); - window.on("close", () => { + window.on("close", (event) => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } persistNormalWindowState(); + // Windows/Linux close-behavior: "tray" hides the window and keeps the + // app running under the tray icon, "ask" prompts on the first close, + // and "quit" (plus macOS and explicit-quit closes) falls through to the + // default close. + if (process.platform === "darwin" || quitting || allowWindowClose) return; + event.preventDefault(); + void (async () => { + if (closeBehavior === "ask") { + if (closePromptOpen) return; + closePromptOpen = true; + try { + const choice = await askCloseBehavior(window); + if (!choice) return; // canceled: keep the window open + applyCloseBehavior(choice); + } finally { + closePromptOpen = false; + } + } + if (closeBehavior === "tray") { + createTrayIcon(); + // The tray is the only way back to a hidden window; if the icon + // could not be created, fall back to a real close instead of + // leaving the app invisible. + if (tray) window.hide(); + else { + allowWindowClose = true; + window.close(); + } + } else { + allowWindowClose = true; + window.close(); + } + })(); }); const boundsWatchdog = setInterval(() => { @@ -5437,6 +5584,26 @@ function registerIpc() { }, ); + // Close-behavior preference (Windows/Linux): read/write the choice the + // settings UI and the first-close prompt share. Only "tray" and "quit" + // are settable — the "ask" state is transient (first close prompts once) + // and once a choice is made it cannot be reverted to prompting. + handle(IPC.invoke.closeBehaviorGet, async () => ({ + behavior: closeBehavior, + supported: process.platform !== "darwin", + })); + + handle(IPC.invoke.closeBehaviorSet, async (input: unknown = {}) => { + const behavior = (input as { behavior?: unknown })?.behavior; + if (behavior !== "tray" && behavior !== "quit") { + throw Object.assign(new Error("invalid close behavior"), { + errorCode: ErrorCodes.INVALID_ARGUMENT, + }); + } + applyCloseBehavior(behavior); + return { behavior }; + }); + ipcMain.handle(IPC.invoke.menuRendererReady, async (event) => wrap(async () => { const window = BrowserWindow.fromWebContents(event.sender); @@ -6649,6 +6816,9 @@ app.whenReady().then(async () => { } } await ensureWindow(); + const storedBehavior = readCloseBehavior(); + if (storedBehavior) closeBehavior = storedBehavior; + if (closeBehavior === "tray") createTrayIcon(); // createWindow awaits the initial load (loadFile resolves on // did-finish-load), so the page is up; give React a beat to mount its // event subscriptions before pushing the boot outcome. @@ -6744,7 +6914,11 @@ app.whenReady().then(async () => { }); app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); + // With a tray present the app owns a background surface: a window closed + // or destroyed for any reason must not take the whole app down — the + // tray click recreates it. Without a tray, closing the last window + // (Windows/Linux) exits the app as before. + if (process.platform !== "darwin" && !tray) app.quit(); }); app.on("before-quit", (event) => { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index c218c6e7c8..b86ec0f767 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -68,6 +68,7 @@ import type { PlansPendingResult, UpdateState, WindowControlAction, + CloseBehavior, } from "@pi-desktop/shared"; import { defaultCommandShellForPlatform, @@ -602,6 +603,14 @@ export const api = { ), windowControl: (action: WindowControlAction) => invoke<{ maximized: boolean }>(IPC.invoke.windowControl, { action }), + getCloseBehavior: () => + invoke<{ behavior: CloseBehavior; supported: boolean }>( + IPC.invoke.closeBehaviorGet, + ), + setCloseBehavior: (behavior: CloseBehavior) => + invoke<{ behavior: CloseBehavior }>(IPC.invoke.closeBehaviorSet, { + behavior, + }), menuRendererReady: () => invoke<{ ready: boolean }>(IPC.invoke.menuRendererReady), nativeMenuAction: (action: NativeMenuAction) => diff --git a/apps/desktop/src/lib/settings-search.ts b/apps/desktop/src/lib/settings-search.ts index e62afde354..186af7185a 100644 --- a/apps/desktop/src/lib/settings-search.ts +++ b/apps/desktop/src/lib/settings-search.ts @@ -45,6 +45,9 @@ export const SETTINGS_NAV: SettingsNavEntry[] = [ "settings.theme", "settings.language", "settings.font", + "settings.closeBehaviorTitle", + "settings.closeBehaviorTray", + "settings.closeBehaviorQuit", "settings.defaultsTitle", "settings.mode", "settings.commandShell", diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx index a142cfc174..8ffb359d96 100644 --- a/apps/desktop/src/pages/SettingsPage.tsx +++ b/apps/desktop/src/pages/SettingsPage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { AppSettings, AgentInstructionFile, + CloseBehavior, CommandShellCatalog, CommandShellId, GlobalPermissionMode, @@ -825,6 +826,85 @@ function ExtensionMarketSection({ ); } +/** + * Windows/Linux only: how closing the main window behaves. The first close + * prompts once (main-process dialog); the remembered choice can be changed + * here between tray and quit, but never reverted to prompting. + */ +function CloseBehaviorSection() { + const { t } = useTranslation(); + const [behavior, setBehavior] = useState(null); + const [saveError, setSaveError] = useState(false); + + useEffect(() => { + let cancelled = false; + void api + .getCloseBehavior() + .then(({ behavior: next }) => { + if (!cancelled) setBehavior(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + // The "ask" state (unset) is transient and cannot be re-selected: once a + // choice is made it is remembered permanently. An unset preference shows + // no active option. + const options: [CloseBehavior, string, string][] = [ + ["tray", "settings.closeBehaviorTray", "settings.closeBehaviorTrayDesc"], + ["quit", "settings.closeBehaviorQuit", "settings.closeBehaviorQuitDesc"], + ]; + + const choose = async (next: CloseBehavior) => { + setSaveError(false); + try { + await api.setCloseBehavior(next); + setBehavior(next); + } catch { + setSaveError(true); + } + }; + + return ( + + +
+ {options.map(([value, labelKey, descKey]) => ( + + ))} +
+
+ {saveError ? ( + + {t("settings.closeBehaviorSaveError")} + + ) : null} +
+ ); +} + export function SettingsPage() { const { t } = useTranslation(); const tab = useAppStore((s) => s.settingsTab); @@ -1192,6 +1272,8 @@ export function SettingsPage() { + {platform !== "darwin" && } +
{ /baseWindowBounds\([\s\S]*window\.getNormalBounds\(\)[\s\S]*workPanelReservation/, ); assert.match(persistenceBlock, /writeWindowState\(bounds\)/); - assert.match(mainSource, /window\.on\("close", \(\) =>/); + assert.match(mainSource, /window\.on\("close", \(event\) =>/); assert.match(mainSource, /persistNormalWindowState\(\)/); }); diff --git a/docs/adr/0090-user-configurable-close-behavior-close-to-tray.md b/docs/adr/0090-user-configurable-close-behavior-close-to-tray.md new file mode 100644 index 0000000000..9414360d92 --- /dev/null +++ b/docs/adr/0090-user-configurable-close-behavior-close-to-tray.md @@ -0,0 +1,85 @@ +# ADR 0090: User-Configurable Close Behavior with Close-to-Tray + +- Status: Accepted for implementation +- Date: 2026-08-12 +- Deciders: PI-Desktop core +- Related: D210, ADR 0021, ADR 0025 + +## Context + +On Windows/Linux, closing the main window calls `app.quit()` +(`window-all-closed`), so the app exits and its taskbar entry disappears. +Minimizing is a plain native minimize and keeps the taskbar entry, but users +with long-running chats expect closing the window to keep the app available — +either minimized in the taskbar or resident in the system tray. Different +users want different defaults, and a fixed close-to-tray behavior would +surprise users who expect close to quit. + +The app has no tray, 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 + +1. 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/set` rejects `ask`). + - `tray`: closing the window hides it and keeps the app running under a + system-tray icon; the tray menu restores the window or quits the app. + - `quit`: legacy behavior — closing the window exits the app. +2. 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). +3. The preference is stored by Electron main in + `/close-behavior.json` (same ownership pattern as + `window-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. +4. Two additive IPC channels expose it to the renderer: + `pi-desktop/window/closeBehavior/get` (returns `{ behavior, supported }`) + and `pi-desktop/window/closeBehavior/set`, which accepts only `tray` and + `quit` (`ask` and unknown values fail with `INVALID_ARGUMENT`). + `supported` is `false` on macOS, where the Settings row is hidden. +5. 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 and reconciles the tray icon: + switching to `tray` creates the icon, switching away destroys it. +6. The close handler intercepts `close` only when `tray` exists or the + preference is `ask`; `before-quit` (`quitting`) and macOS closes always + fall through, so an explicit quit, the tray Quit item, and the automated + boot probe are unaffected. `window-all-closed` quits only when no tray + exists, keeping the app alive when the window is tray-hidden or + destroyed unexpectedly. +7. 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. +8. Known limitation: on Windows system shutdown/logoff, the OS may deliver + a `close` that is intercepted while the preference is `ask` or `tray`. + 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 always present:** rejected; without tray behavior the icon is + dead weight, so it is created lazily and reconciled with the preference. + +## Consequences + +- Minimize always keeps the taskbar/dock entry (native minimize). +- 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/i18n` catalogs (English and Simplified Chinese). +- No host protocol, storage schema, or macOS behavior changes. diff --git a/docs/adr/README.md b/docs/adr/README.md index 73a574a6aa..ed19ced3e5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -106,3 +106,4 @@ Each ADR includes: | 0087 | Replace textual Edit matching with a line-anchored, tag-verified contract | Accepted for implementation (amends 0043 / 0069) | | 0088 | Declared file scope and recoverable deletion for plugins | Accepted (continues 0008 D009) | | 0089 | Proactive background subagent delegation | Accepted for implementation | +| 0090 | User-configurable close behavior with close-to-tray | Accepted for implementation | diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 48556db2a2..9caac0472f 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1052,6 +1052,26 @@ window/control({ action: WindowControlAction }) -> { maximized: boolean } ``` +Windows/Linux close behavior (D210, ADR 0090) is read and written through +two additive Main-owned channels. `closeBehavior/get` returns the persisted +preference and whether the platform supports it (macOS keeps the native +Dock lifecycle and reports `supported: false`); `closeBehavior/set` +accepts a settable `CloseBehavior` (`tray` or `quit`) and reconciles the +tray icon with the choice: + +```ts +type CloseBehavior = "ask" | "tray" | "quit"; + +window/closeBehavior/get -> { behavior: CloseBehavior; supported: boolean } +window/closeBehavior/set({ behavior: "tray" | "quit" }) + -> { behavior: "tray" | "quit" } +``` + +`ask` is the transient unset state reported by `get`; it is never settable +— the first close prompts once, and once a choice exists it can be switched +but not reverted to prompting. `ask` and unknown values fail with +`INVALID_ARGUMENT` rather than being coerced. + Maximize/unmaximize changes also emit `window/event/maximized`. Unknown actions fail. These Electron-only channels do not cross into host-core and do not change the host RPC protocol version. diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index c56112f17a..2346181821 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -113,6 +113,16 @@ recency only breaks ties between equally relevant matches. drag region. Maximize state is queried on mount and updated from native window events, so the restore affordance never depends only on optimistic renderer state. +- Minimize is always a native minimize and keeps the taskbar/dock entry. + Windows/Linux close behavior is user-configurable (ADR 0090): an unset + preference asks once via a native prompt (Cancel / Close to tray / Quit); + `tray` hides the window and keeps the app running under a system-tray + icon whose click restores the window; `quit` exits the app. The choice is + persisted, revisitable in Settings → General, and applied by both the + close button and the close shortcut. macOS keeps the native Dock + lifecycle (close keeps the app in the Dock; activating recreates the + window). The bounds watchdog never restores a minimized or tray-hidden + window. ### 1.5.1 Tray-resident minimize diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index ae5ee030b8..aabffd3a17 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -2190,6 +2190,42 @@ Each scenario is documented in this format: platform bridge, native menu installation, and the pre-render maximize fixture on Windows/Linux; native visual scenario Draft +#### E2E-120: Close behavior is asked once and stays configurable (D210) + +- **Preconditions**: Windows/Linux run with a clean data dir (no + `close-behavior.json`); the main window is visible; Settings → General is + reachable. macOS is excluded: it keeps the native Dock lifecycle. +- **Steps**: 1) With the preference unset, click the close button (or press + the close-window shortcut) and answer the prompt: Cancel keeps the window + open and the preference unset; Close to tray hides the window, shows the + tray icon, and the app keeps running (a turn in flight stays live); Quit + exits the app. 2) Re-run each choice and verify it is remembered across a + full restart and that no second prompt ever appears once a choice exists. + 3) With `tray` set, click the tray icon: the window + restores, shows, and focuses; the tray context menu offers Open and Quit, + and Quit exits the app. 4) In Settings → General, switch between Close to + tray / Quit app and verify the next close follows the new + choice, the tray icon appears only for Close to tray, an unset preference + shows no selection, and search matches + the row. 5) Minimize from any setting and verify the taskbar entry + remains and the window does not restore itself. 6) Invoke unknown values + and `"ask"` on `pi-desktop/window/closeBehavior/set` and verify they fail + closed. +- **Expected**: The first close prompts exactly once per unset state and + Cancel never persists a choice. Tray mode keeps the app alive with a + localized tooltip/menu and no data loss; switching to Quit app destroys + the tray icon; the preference survives restarts and is honored by both + the window-control close button and the close shortcut. Minimize keeps + the native taskbar entry in every mode, and the bounds watchdog never + force-restores a minimized or tray-hidden window. The automated boot + probe (`app.quit`) exits without prompting. +- **Specs linked**: `03-runtime/01-ipc-protocol.md`, + `04-ux/01-ui-ia.md`, `04-ux/09-interaction-patterns.md`, + `08-meta/decisions-log.md` (D210), ADR 0090 +- **Acceptance**: A (app startup), Quality +- **Milestone**: M5 on Windows/Linux (release qualification) +- **Status**: Draft + #### E2E-067A: Prerelease install discovers newer stable release (D120) - **Preconditions**: Packaged build whose embedded version is a prerelease such diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 19122548dd..fce067e14e 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -235,9 +235,7 @@ Gold source: local Codex electron captures; latest row wins where rows conflict. | D205 | ChatGPT-inspired empty-home guidance | *(superseded by D206)* **The empty chat home adds a compact four-card developer starter grid between the hero and optional checklist: Explore a codebase, Build a feature, Fix a bug, and Review a change. Each localized card only prefills and focuses the bottom composer; it never sends a prompt or creates a turn. The bottom-reserved composer and single scrollable home flow from D204 remain unchanged.** | The previous hero-only middle left too much unused space and offered no starting cues. ChatGPT's clear empty-state hierarchy improves first-task discoverability while developer-specific prompts keep the surface purposeful rather than promotional. | | D206 | Remove empty-home developer starter cards | **The empty chat home does not render developer starter cards, starter glyphs, or a contextual quick-action row. It keeps the restrained hero, short supporting line, optional onboarding checklist, and D204's bottom-reserved composer; task entry starts directly in the composer. This supersedes D205 without changing D204's scroll and bottom-reservation layout.** | Review confirmed that the direct composer is the preferred task-entry surface and that the cards add an unnecessary decision layer to the empty home. | | D208 | Recoverable native-tool path contracts | **Keep D185's deferred Glob/Grep boundary, but make every prompt and schema explicit that Read accepts an existing regular file, Glob accepts a directory, and Grep accepts a file or directory. A directory Read returns `INVALID_ARGUMENT` plus structured Glob recovery args; an explicit-file Grep searches only that file and applies `include` to its basename. Tool errors remain visible on their ToolCallRows, while activity groups report processing duration only and never infer terminal turn failure from a child row; terminal agent events and the dedicated outcome surfaces remain authoritative (ADR 0069).** | Durable sessions showed directory Read and file-as-directory Grep mistakes repeatedly, then displayed recovered work as terminally failed. Compatibility at the narrow host boundary plus one outcome owner removes retries and false failure UI without restoring every search schema to the Agent core. | -| D216 | Cross-platform tray-resident minimize | **Electron Main creates one packaged-resource tray icon on macOS, Windows, and Linux. Every main-window minimize path is intercepted and hides the window without disposing the host or sidecar; tray click/double-click, Show, and macOS app activation restore and focus the existing window (or create one if it was closed). The localized tray menu exposes Show PI-Desktop and an explicit Quit PI-Desktop action. Closing the window remains a quit action, and tray destruction plus Quit use the existing ordered `before-quit` shutdown path.** | Users need background work to continue without losing the app window, while minimizing must mean the same thing across the native macOS controls and the custom Windows/Linux shell. Main-owned tray lifecycle avoids renderer privilege expansion and keeps explicit exit observable. | -| D218 | Host-owned cross-platform plugin panel chrome | **Plugin panel windows adopt the main window's 46px platform chrome: macOS uses `hiddenInset` with traffic lights at `{x:16,y:16}`, while Windows/Linux are frameless with a 112px custom minimize/maximize-or-restore/close band. The sandboxed plugin preload renders the manifest title and controls in a closed Shadow DOM, offsets content by the titlebar height in addition to existing top padding, and consumes a private sender-validated fixed window-action channel; `window.pluginBridge`, the per-plugin partition, and host protocol v9 do not expand. Reopening a minimized panel restores and focuses it.** | Default Electron frames made plugin tools look detached from PI-Desktop and varied by platform. Preload-owned chrome provides parity without moving untrusted plugin HTML into the host renderer or exposing general Electron window authority (ADR 0081). | -| D219 | Custom global UI font | **Settings → Basics → Appearance gains a searchable Font picker (trigger previews the current family). Selections persist as `AppSettings.fontFamily`, a CSS stack; absent means the built-in `--font-sans` token stack, and the renderer applies the stack by overriding `--font-sans` on the root element without a reload. Four bundled families — Geist, Inter, Noto Sans SC, LXGW WenKai — ship locally as woff2 under the SIL OFL 1.1 with license texts; every custom stack appends a CJK fallback tier and the mono stack is unchanged. Installed system families are enumerated by Electron main using platform tooling only (macOS: `osascript` JXA bridging the CoreText query `CTFontManagerCopyAvailableFontFamilyNames` — the same API dbx's `font_kit::all_families()` calls — with `system_profiler` as a slow fallback; Windows: PowerShell; Linux: `fc-list`), deduplicated/sorted/filtered and cached 60 s, exposed through the additive allowlisted channel `pi-desktop/app/systemFonts`; host protocol v9 and storage schema v10 are unchanged.** | Users want a Codex/dbx-style global font preference, but the sandboxed renderer cannot enumerate OS fonts and the host RPC should stay unchanged for a renderer-only preference. Bundling OFL-licensed families keeps every offered font commercially safe and offline, while the fast CoreText path returns the canonical CSS family names (e.g. PingFang SC) in tens of milliseconds and the 60 s cache bounds repeated enumeration (ADR 0083). | +| D210 | User-configurable close behavior with close-to-tray | **Windows/Linux close behavior is a persisted preference stored by Electron main in `/close-behavior.json`. `ask` is the transient unset state: the first close shows a native modal (Cancel / Close to tray / Quit); picking one persists it forever, Cancel keeps the window open and unset. Only `tray` and `quit` are ever settable — Settings -> General renders a two-option radio segment (Close to tray / Quit app) for Windows/Linux only, and `pi-desktop/window/closeBehavior/set` rejects `ask`, so a choice can be switched but never reverted to prompting. `tray` hides the window and keeps the app running under a lazily created system-tray icon (click restores, menu shows or quits); switching away destroys the icon. Close interception only runs for `ask`/`tray` while `quitting` and macOS closes always fall through, and `window-all-closed` quits only when no tray exists, so the boot probe and explicit quits are unaffected. The bounds watchdog skips minimized and hidden windows so minimize keeps its native taskbar entry and a tray-hidden window is never force-restored. macOS keeps the native Dock lifecycle (ADR 0090).** | Minimizing already keeps the taskbar entry; the gap was close: it quit outright on Windows/Linux. A fixed close-to-tray would surprise users who expect exit, so the choice is asked once, remembered, and revisitable in Settings — matching how Codex-style shells keep long-running sessions alive without taking over the close button. | ## P. Transcript storage decisions diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 209c3f7e20..ff0c53a640 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -22,8 +22,12 @@ export const en = { close: "Close", }, tray: { - show: "Show PI-Desktop", + open: "Open PI-Desktop", quit: "Quit PI-Desktop", + askTitle: "Keep PI-Desktop running in the background?", + askBody: + "When you close the window, PI-Desktop can keep running in the system tray so nothing is lost. You can change this any time in Settings.", + closeToTray: "Close to tray", }, /** * Native consent dialog for a file access a plugin's manifest did not @@ -598,6 +602,14 @@ export const en = { fullAccessLearnMoreAfter: " about elevated risks.", showMenuBar: "Show in menu bar", showMenuBarDesc: "Keep PI-Desktop in the macOS menu bar when the main window is closed.", + closeBehaviorTitle: "Close behavior", + closeBehaviorDesc: + "What happens when you close the main window. macOS always keeps the app in the Dock.", + closeBehaviorTray: "Close to tray", + closeBehaviorTrayDesc: "Keep PI-Desktop running in the system tray.", + closeBehaviorQuit: "Quit app", + closeBehaviorQuitDesc: "Exit PI-Desktop completely.", + closeBehaviorSaveError: "Couldn't save the close behavior.", defaultsTitle: "Defaults", defaultModel: "Default model", defaultModelDesc: "Used by chats that haven't chosen their own model. Changes apply from the next message.", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 5180f041f7..033e9200c8 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -23,8 +23,12 @@ export const zhCN = { close: "关闭", }, tray: { - show: "显示 PI-Desktop", + open: "打开 PI-Desktop", quit: "退出 PI-Desktop", + askTitle: "关闭后继续在后台运行 PI-Desktop?", + askBody: + "关闭窗口后,PI-Desktop 可以继续在系统托盘中运行,避免丢失任何内容。你随时可以在设置中更改此选项。", + closeToTray: "关闭到托盘", }, pluginFsConsent: { read: "{name} 想读取声明范围之外的文件", @@ -592,6 +596,13 @@ export const zhCN = { fullAccessLearnMoreAfter: " 关于风险升高的信息。", showMenuBar: "在菜单栏中显示", showMenuBarDesc: "主窗口关闭后,仍将 PI-Desktop 保留在 macOS 菜单栏。", + closeBehaviorTitle: "关闭行为", + closeBehaviorDesc: "关闭主窗口时发生什么。macOS 始终会将应用保留在 Dock 中。", + closeBehaviorTray: "关闭到托盘", + closeBehaviorTrayDesc: "在系统托盘中继续运行 PI-Desktop。", + closeBehaviorQuit: "退出应用", + closeBehaviorQuitDesc: "完全退出 PI-Desktop。", + closeBehaviorSaveError: "无法保存关闭行为设置。", defaultsTitle: "默认项", defaultModel: "默认模型", defaultModelDesc: "尚未单独选择模型的对话会使用它。更改从下一条消息开始生效。", diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index b5bdad52e7..d2ad090723 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -189,6 +189,8 @@ export const IPC = { windowSetWorkPanelReservation: "pi-desktop/window/setWorkPanelReservation", windowControl: "pi-desktop/window/control", + closeBehaviorGet: "pi-desktop/window/closeBehavior/get", + closeBehaviorSet: "pi-desktop/window/closeBehavior/set", menuRendererReady: "pi-desktop/menu/rendererReady", nativeMenuAction: "pi-desktop/menu/nativeAction", }, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4dba54af57..e3738d9146 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -643,6 +643,16 @@ export type ModelInfo = { */ export type ThemePreference = "system" | "light" | "dark" | `plugin:${string}`; +/** + * What closing the main window does on Windows/Linux. macOS keeps the native + * Dock lifecycle and never consults this preference. + * - `ask`: transient unset state — the first close prompts once; after a + * choice is made it is remembered permanently and cannot be reverted + * - `tray`: hide to the system tray; the app keeps running in the background + * - `quit`: close the window and exit the app (legacy behavior) + */ +export type CloseBehavior = "ask" | "tray" | "quit"; + export type AppSettings = { defaultProviderId?: string; defaultModelId?: string;