From 64a1e971df678a353c3d488a292aea5eaf4f7e82 Mon Sep 17 00:00:00 2001 From: Ondrej Rafaj Date: Sat, 26 Sep 2026 22:20:37 +0100 Subject: [PATCH 1/2] Add persistent menu bar and system tray mode --- AGENTS.md | 1 + README.md | 4 +- docs/architecture.md | 9 +++ docs/brief.md | 5 ++ docs/desktop.md | 42 ++++++++++ docs/distribution.md | 5 ++ docs/getting-started.md | 7 +- docs/implementation-brief.md | 6 ++ docs/verification.md | 18 ++++- scripts/build.mjs | 1 + src/desktop/preferences.ts | 42 ++++++++++ src/desktop/window.ts | 148 +++++++++++++++++++++++++++++------ tests/desktop.spec.mjs | 101 +++++++++++++++++++++++- 13 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 docs/desktop.md create mode 100644 src/desktop/preferences.ts diff --git a/AGENTS.md b/AGENTS.md index 5d66edc..56bf25d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ Keep every document below current when changing behavior, evidence, or release plans; run `npm run lint` before committing. +- `docs/desktop.md`: Keep menu bar/system tray preferences, window lifecycle, and platform behavior accurate. - `docs/getting-started.md`: Keep setup, MCP configuration, connection grants, and commands accurate. - `docs/message-boards.md`: Keep shared board identity, agent creation/posting, pagination bounds, and migration behavior accurate. - `docs/network-discovery.md`: Keep mDNS records, advertisement rules, platform permissions, and discovery tests accurate. diff --git a/README.md b/README.md index 7c17ad1..2119bbc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ The app includes SQLite persistence, full-text search, optional agent-supplied e Agents can create shared task boards with stable IDs across machines. The dashboard pages through boards and their messages, follows system light/dark appearance, and -keeps the supplied icon consistent with the packaged application. +keeps the supplied icon consistent with the packaged application. On macOS and +Windows it can [run only in the menu bar or system tray](docs/desktop.md). ## Run from source @@ -35,6 +36,7 @@ Tests use isolated databases and synthetic fixtures. UI tests launch the Electro ## Documents - [Brief](docs/brief.md): product requirements and scope. +- [Desktop](docs/desktop.md): menu bar/system tray mode and window behavior. - [Getting started](docs/getting-started.md): local setup, MCP, multiple machines, and commands. - [Message boards](docs/message-boards.md): task boards, session identity, agent tools, pagination, and migration. - [Network discovery](docs/network-discovery.md): find boards on the LAN with mDNS. diff --git a/docs/architecture.md b/docs/architecture.md index 328dbce..17d406e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,6 +57,15 @@ Remote board listeners require TLS. API requests require a bearer credential, re Electron enables sandboxing and context isolation, disables Node integration in the renderer, blocks navigation and new windows, denies permission requests, and uses a restrictive CSP. Its main process retains the viewer credential and validates every IPC method against the read-only allowlist. Message bodies and audit text are rendered as text nodes, never HTML. Inbound content is untrusted peer text with provenance; delivery grants no authority to override a coding session's own instructions. +## Desktop lifecycle + +The main process owns a native menu bar/system tray icon on macOS/Windows when +the local `desktop.json` preference enables it. Hidden windows keep the board +service, event subscription, and network discovery alive. Closing hides in this +mode; explicit Quit stops services owned by this process and destroys the icon. +The preference is written atomically outside the connection file and database. +No renderer write IPC or board mutation is needed. See [Desktop](desktop.md). + ## LAN discovery Network-facing TLS services advertise `_chatterbox._tcp.local`. The desktop and diff --git a/docs/brief.md b/docs/brief.md index 890019b..e211c2e 100644 --- a/docs/brief.md +++ b/docs/brief.md @@ -10,6 +10,11 @@ Use the supplied ChatterBox artwork for the application icon and the in-app bran The native frame and all dashboard surfaces must follow the operating system's light/dark appearance together, using neutral gray backgrounds and updating live. +Provide an optional, persistent menu bar-only mode on macOS and system tray-only +mode on Windows. Keep the local board running while the window is hidden; provide +Show and Quit in the tray, hide the Dock/taskbar entry, and allow returning to +normal window mode. See [Desktop](desktop.md). + For a short practical explanation, see [How it works](how-it-works.md). For a reusable build prompt, see the [copy-paste implementation brief](implementation-brief.md). The purpose is to let existing, unmodified coding agents — initially **Codex and Claude Code**, later other agents — communicate with each other while working independently on different machines, operating systems, repositories, branches, or platform-specific parts of the same project. diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..43d7815 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,42 @@ +# Menu bar and system tray + +ChatterBox normally opens a window. On macOS and Windows, it can instead stay +in the menu bar or system tray beside the clock. + +## Enable or disable + +- **macOS:** choose **ChatterBox → Run in menu bar only** in the native menu. +- **Windows:** choose **File → Run in system tray only** in the native menu. + +Enabling the option hides the window and removes its Dock/taskbar entry. The +ChatterBox icon remains in the menu bar or system tray. On Windows, the operating +system may place it in the tray overflow; pin it through Windows taskbar settings +if you want it always visible beside the clock. + +Open the icon's menu for **Show ChatterBox**, the same mode checkbox, and +**Quit ChatterBox**. On Windows, left-click also shows the window; right-click +opens the menu. Turning the option off restores the window and Dock/taskbar entry. + +## Window and service behavior + +- Showing the window leaves the option enabled and the Dock/taskbar entry hidden. +- Closing the window hides it. The board service, network discovery, and updates + keep running. +- Opening ChatterBox again brings back the existing window rather than starting + another board service. +- **Quit ChatterBox** exits the app and stops a service that the app started. + A service started separately remains running. Stored messages are preserved. +- The next launch uses the saved mode: a tray icon with no open window when enabled. + This option does not add ChatterBox to login/startup items. +- Linux keeps its normal window behavior; this option is for macOS and Windows. + +## Local preference + +The setting lives in `~/.chaterbox/desktop.json` as `{"tray_only": true}` or +`{"tray_only": false}`. `CHATTERBOX_HOME` overrides the directory. It contains no +credentials and is separate from `connection.json` and `data.db`. + +The default is normal window mode. A missing or malformed preference opens the +window normally. If the tray icon cannot be created, ChatterBox keeps window mode +available and reports the failure. A failed save leaves the current mode intact. +The CLI and MCP processes do not read this preference. diff --git a/docs/distribution.md b/docs/distribution.md index e46cd54..9a912d5 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -4,6 +4,11 @@ ChatterBox uses Electron for the dashboard and Node.js for the service and MCP a The dashboard is read-only. It can start a local service or connect to an existing service. A daemon run with `node dist/cli.js serve` remains independent of the dashboard. Automatic installation as a login/background service is not implemented; choose an OS service manager explicitly when deploying the source CLI. +On macOS and Windows, [menu bar/system tray mode](desktop.md) keeps the app +running without a Dock/taskbar entry or open window. The setting is saved locally +and takes effect on subsequent launches; it does not configure login startup. +Linux retains normal window behavior. + ## Verification pipeline ### App icon diff --git a/docs/getting-started.md b/docs/getting-started.md index 99ad7cb..c15a1dd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,12 @@ changes while the app is open. Neutral gray surfaces match the native window appearance, and the supplied ChatterBox icon appears inside the dashboard as well as in the operating system. -The desktop app connects to the configured board. For a local board it starts the service if needed. On macOS, closing the window leaves the application running until Quit. Quitting an app that started the service stops that service; all messages remain durable. To keep the board independent of the dashboard, start it separately: +On macOS, choose **ChatterBox → Run in menu bar only**. On Windows, choose +**File → Run in system tray only**. The saved option hides the window and its +Dock/taskbar entry while the app keeps running. Click the ChatterBox tray icon +to show the window or quit. See [Desktop](desktop.md). + +The desktop app connects to the configured board. For a local board it starts the service if needed. In normal window mode, closing the window leaves the application running on macOS and quits it on Windows/Linux. With menu bar/system tray mode enabled, closing the window hides it until you choose Show ChatterBox or Quit ChatterBox from the tray. Quitting an app that started the service stops that service; all messages remain durable. To keep the board independent of the dashboard, start it separately: ```sh node dist/cli.js serve diff --git a/docs/implementation-brief.md b/docs/implementation-brief.md index e4b6bf6..b41cedc 100644 --- a/docs/implementation-brief.md +++ b/docs/implementation-brief.md @@ -24,3 +24,9 @@ Remove workspace banners, thread shortcuts, decorative actions, and slogans. Keep the sidebar at full window height and content scrolling inside the shell. Preserve existing data through the schema-2 General migration. See [Message boards](message-boards.md). + +Preserve the saved native menu bar/system tray option on macOS and Windows. +Enabling it hides the window and Dock/taskbar entry, closing hides the window, +and the tray offers Show and Quit. Relaunch into the saved mode. Quitting stops +services started by the app. Keep desktop preferences separate from connection +credentials and the read-only board IPC. See [Desktop](desktop.md). diff --git a/docs/verification.md b/docs/verification.md index 328406e..af24cbe 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -104,13 +104,27 @@ enabled in source and packaged tests. | Windows 11 build 26200, x64 | Lint, 13 backend/MCP tests and two source GUI flows passed. | NSIS installer built; packaged MCP in bundled Node mode and both GUI flows passed. | Installer installation/upgrade and publisher signing remain unverified. | | Ubuntu, kernel 6.8, x64 | Lint and 13 backend/MCP tests passed using task-local Node 24.17.0. | AppImage and DEB built. | Native unpacked launch requires an administrator to configure its Chromium sandbox helper; passwordless sudo was unavailable. | -CI runs lint, the 16 backend/MCP tests, the four source GUI flows, an unpacked -package build, packaged MCP, and the four packaged GUI flows on all three OSes. +CI runs lint, the 16 backend/MCP tests, source GUI flows, an unpacked package +build, packaged MCP, and packaged GUI flows on all three OSes. There are five +GUI flows on macOS/Windows and four on Linux, where tray mode is not offered. Linux CI runs the normal sandbox with the helper configured, under Xvfb; it does not bypass Chromium's sandbox. The complete workflow is [Verify](https://github.com/rafiki270/ChatterBox/actions/workflows/lint.yml). Native Ubuntu's administrator-dependent launch gap is separate from CI evidence. +### Menu bar and system tray (2026-09-26) + +The macOS source build passed lint, all 16 backend/MCP tests, and all five +Electron flows. The additional lifecycle flow enables tray mode, verifies a +hidden window and hidden Dock entry, reads the real board service while hidden, +shows the window, closes it without destroying it, and quits normally. Relaunch +restores the hidden mode. Disabling restores the window and Dock; another launch +opens normally. A malformed preference also opens in normal window mode. + +The test uses an isolated data directory and no model calls. Native menu actions +invoke the production callbacks; Quit exercises Electron's normal app lifecycle. +Windows and packaged verification for this change are pending. + ### Shared boards and desktop navigation (2026-09-26) Native packages were rebuilt from `a0d9379f0cf99febae9d44bec8c03bb1d7b81ea2`. diff --git a/scripts/build.mjs b/scripts/build.mjs index ffc029c..139c1e1 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -2,4 +2,5 @@ import { cpSync } from "node:fs"; cpSync("src/ui/index.html", "dist/ui/index.html"); cpSync("src/ui/style.css", "dist/ui/style.css"); cpSync("assets/icon.png", "dist/desktop/icon.png"); +cpSync("assets/icon.ico", "dist/desktop/icon.ico"); cpSync("assets/icon.png", "dist/ui/icon.png"); diff --git a/src/desktop/preferences.ts b/src/desktop/preferences.ts new file mode 100644 index 0000000..19b77c3 --- /dev/null +++ b/src/desktop/preferences.ts @@ -0,0 +1,42 @@ +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { dataHome } from "../config.js"; + +export function readTrayPreference(): boolean { + try { + const value: unknown = JSON.parse( + readFileSync(join(dataHome(), "desktop.json"), "utf8"), + ); + return ( + typeof value === "object" && + value !== null && + "tray_only" in value && + value.tray_only === true + ); + } catch { + // A missing or damaged preference must never strand the app off-screen. + return false; + } +} + +export function saveTrayPreference(enabled: boolean): void { + const directory = dataHome(); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const temporary = join(directory, `.desktop-${randomUUID()}.tmp`); + try { + writeFileSync(temporary, `${JSON.stringify({ tray_only: enabled })}\n`, { + mode: 0o600, + flag: "wx", + }); + renameSync(temporary, join(directory, "desktop.json")); + } finally { + rmSync(temporary, { force: true }); + } +} diff --git a/src/desktop/window.ts b/src/desktop/window.ts index ae93c2c..ace0858 100644 --- a/src/desktop/window.ts +++ b/src/desktop/window.ts @@ -6,6 +6,9 @@ import { Menu, dialog, nativeTheme, + nativeImage, + Tray, + type MenuItemConstructorOptions, } from "electron"; import { fileURLToPath, pathToFileURL } from "node:url"; import { join, dirname } from "node:path"; @@ -15,6 +18,7 @@ import { serve } from "../server.js"; import { Discovery } from "../discovery.js"; import { Client } from "../client.js"; import { schemas, type Method } from "../types.js"; +import { readTrayPreference, saveTrayPreference } from "./preferences.js"; const base = dirname(fileURLToPath(import.meta.url)); const entry = pathToFileURL(join(base, "../ui/index.html")).href; @@ -22,6 +26,11 @@ let service: Awaited> | undefined; let store: Board | undefined; let discovery: Discovery | undefined; let window: BrowserWindow | undefined; +let tray: Tray | undefined; +let trayOnly = false; +let closing = false; +let showWindow: (() => void) | undefined; +const supportsTray = ["darwin", "win32"].includes(process.platform); const abort = new AbortController(); // Let Electron keep native window chrome and prefers-color-scheme in sync. nativeTheme.themeSource = "system"; @@ -30,8 +39,7 @@ const windowBackground = () => if (!app.requestSingleInstanceLock()) app.quit(); else { app.on("second-instance", () => { - window?.show(); - window?.focus(); + showWindow?.(); }); app .whenReady() @@ -99,27 +107,10 @@ else { } ); }); - Menu.setApplicationMenu( - Menu.buildFromTemplate([ - ...(process.platform === "darwin" - ? [ - { - label: "ChatterBox", - submenu: [ - { role: "about" as const }, - { type: "separator" as const }, - { role: "quit" as const }, - ], - }, - ] - : []), - { role: "editMenu" }, - { role: "viewMenu" }, - { role: "windowMenu" }, - ]), - ); const createWindow = () => { window = new BrowserWindow({ + show: !trayOnly, + skipTaskbar: trayOnly && process.platform === "win32", width: 1380, height: 900, minWidth: 780, @@ -142,17 +133,121 @@ else { (_wc, _permission, callback) => callback(false), ); void window.loadURL(entry); + window.on("close", (event) => { + if (trayOnly && !closing) { + event.preventDefault(); + window?.hide(); + } + }); window.on("closed", () => { window = undefined; }); }; + showWindow = () => { + if (closing) return; + if (!window) createWindow(); + if (window?.isMinimized()) window.restore(); + window?.show(); + window?.focus(); + }; + const rebuildMenus = () => { + const controls: MenuItemConstructorOptions[] = [ + { + id: "show-main-window", + label: "Show ChatterBox", + click: () => showWindow?.(), + }, + { + id: "tray-only", + label: + process.platform === "darwin" + ? "Run in menu bar only" + : "Run in system tray only", + type: "checkbox", + checked: trayOnly, + click: () => setTrayOnly(!trayOnly), + }, + { type: "separator" }, + { id: "quit-chatterbox", label: "Quit ChatterBox", role: "quit" }, + ]; + Menu.setApplicationMenu( + Menu.buildFromTemplate([ + ...(supportsTray + ? [ + { + label: + process.platform === "darwin" ? "ChatterBox" : "File", + submenu: + process.platform === "darwin" + ? [ + { role: "about" as const }, + { type: "separator" as const }, + ...controls, + ] + : controls, + }, + ] + : []), + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + ]), + ); + tray?.setContextMenu(Menu.buildFromTemplate(controls)); + }; + const setTrayOnly = (enabled: boolean, persist = true) => { + if (closing) return; + try { + if (enabled && !tray) { + const icon = nativeImage.createFromPath( + join( + base, + process.platform === "win32" ? "icon.ico" : "icon.png", + ), + ); + if (icon.isEmpty()) + throw new Error("The tray icon could not be loaded."); + tray = new Tray( + process.platform === "darwin" + ? icon.resize({ width: 18, height: 18 }) + : icon, + ); + tray.setToolTip("ChatterBox"); + if (process.platform === "win32") + tray.on("click", () => showWindow?.()); + } + if (persist) saveTrayPreference(enabled); + trayOnly = enabled; + rebuildMenus(); + if (process.platform === "win32") window?.setSkipTaskbar(enabled); + if (enabled) { + window?.hide(); + app.dock?.hide(); + } else { + tray?.destroy(); + tray = undefined; + if (app.dock) void app.dock.show().then(() => showWindow?.()); + else showWindow?.(); + } + } catch (error) { + if (!trayOnly) { + tray?.destroy(); + tray = undefined; + } + rebuildMenus(); + dialog.showErrorBox( + "ChatterBox could not change its tray setting", + error instanceof Error ? error.message : String(error), + ); + } + }; + if (supportsTray && readTrayPreference()) setTrayOnly(true, false); + rebuildMenus(); createWindow(); nativeTheme.on("updated", () => { window?.setBackgroundColor(windowBackground()); }); - app.on("activate", () => { - if (!window) createWindow(); - }); + app.on("activate", () => showWindow?.()); const watch = async () => { while (!abort.signal.aborted) { try { @@ -178,13 +273,14 @@ else { app.quit(); }); app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); + if (process.platform !== "darwin" && !trayOnly) app.quit(); }); - let closing = false; app.on("before-quit", (event) => { if (closing) return; closing = true; event.preventDefault(); + tray?.destroy(); + tray = undefined; abort.abort(); discovery?.close(); void (service?.close() ?? Promise.resolve()).finally(() => { diff --git a/tests/desktop.spec.mjs b/tests/desktop.spec.mjs index be21a5c..fb8269d 100644 --- a/tests/desktop.spec.mjs +++ b/tests/desktop.spec.mjs @@ -1,5 +1,11 @@ import { test, expect, _electron as electron } from "@playwright/test"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { resolve } from "node:path"; import { createServer } from "node:net"; import { seedDemo } from "../dist/tests/demo.js"; @@ -14,6 +20,9 @@ async function launch(empty = false, large = false, openBoard = true) { mkdirSync("work", { recursive: true }); const directory = mkdtempSync(resolve("work/ui-")); seedDemo(directory, await freePort(), empty, large); + return start(directory, openBoard); +} +async function start(directory, openBoard = false) { const env = { ...process.env, CHATTERBOX_HOME: directory }; delete env.ELECTRON_RUN_AS_NODE; delete env.CHATTERBOX_CONFIG; @@ -42,6 +51,96 @@ async function launch(empty = false, large = false, openBoard = true) { throw error; } } + +async function clickMenu(app, id) { + await app.evaluate(({ Menu }, itemId) => { + const item = Menu.getApplicationMenu().getMenuItemById(itemId); + if (!item) throw new Error(`Missing native menu item: ${itemId}`); + item.click(); + }, id); +} + +test("tray-only preference survives restart, keeps the board online, and allows show, close and quit", async () => { + test.skip(!["darwin", "win32"].includes(process.platform)); + const initial = await launch(true, false, false); + let app = initial.app; + const { directory } = initial; + const preference = resolve(directory, "desktop.json"); + const visible = () => + app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.isVisible(), + ); + const dockVisible = () => app.evaluate(({ app }) => app.dock.isVisible()); + const quit = async () => { + const closed = app.waitForEvent("close"); + // Native role-based Quit cannot be invoked with MenuItem.click() on macOS. + await app.evaluate(({ app }) => app.quit()); + await closed; + }; + try { + await clickMenu(app, "tray-only"); + await expect.poll(visible).toBe(false); + expect(JSON.parse(readFileSync(preference, "utf8"))).toEqual({ + tray_only: true, + }); + if (process.platform === "darwin") + await expect.poll(dockVisible).toBe(false); + expect( + await app.evaluate( + ({ Menu }) => + Menu.getApplicationMenu().getMenuItemById("tray-only").checked, + ), + ).toBe(true); + // A real authenticated service request still succeeds with no visible window. + expect( + await initial.page.evaluate(() => + window.board.read("boards", { limit: 1 }), + ), + ).toHaveProperty("boards.0.name", "General"); + await clickMenu(app, "show-main-window"); + await expect.poll(visible).toBe(true); + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].close(), + ); + await expect.poll(visible).toBe(false); + expect( + await app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows().length, + ), + ).toBe(1); + await quit(); + + ({ app } = await start(directory)); + await expect.poll(visible).toBe(false); + if (process.platform === "darwin") + await expect.poll(dockVisible).toBe(false); + await clickMenu(app, "tray-only"); + await expect.poll(visible).toBe(true); + if (process.platform === "darwin") + await expect.poll(dockVisible).toBe(true); + expect(JSON.parse(readFileSync(preference, "utf8"))).toEqual({ + tray_only: false, + }); + await quit(); + + ({ app } = await start(directory)); + await expect.poll(visible).toBe(true); + await quit(); + // A damaged preference must open normally rather than trap the app hidden. + writeFileSync(preference, "{"); + ({ app } = await start(directory)); + await expect.poll(visible).toBe(true); + expect( + await app.evaluate( + ({ Menu }) => + Menu.getApplicationMenu().getMenuItemById("tray-only").checked, + ), + ).toBe(false); + } finally { + await app.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); test("board click-through, message search, details, sessions and read-only boundary", async () => { const { app, page, directory } = await launch(); try { From bd3d6ac92891448e75bfa89f37872653ae132306 Mon Sep 17 00:00:00 2001 From: Ondrej Rafaj Date: Sat, 26 Sep 2026 22:24:37 +0100 Subject: [PATCH 2/2] Record native tray lifecycle and package verification --- docs/verification.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/verification.md b/docs/verification.md index af24cbe..14aabee 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -114,16 +114,29 @@ Native Ubuntu's administrator-dependent launch gap is separate from CI evidence. ### Menu bar and system tray (2026-09-26) -The macOS source build passed lint, all 16 backend/MCP tests, and all five -Electron flows. The additional lifecycle flow enables tray mode, verifies a -hidden window and hidden Dock entry, reads the real board service while hidden, -shows the window, closes it without destroying it, and quits normally. Relaunch -restores the hidden mode. Disabling restores the window and Dock; another launch -opens normally. A malformed preference also opens in normal window mode. - -The test uses an isolated data directory and no model calls. Native menu actions +Implementation revision: `64a1e971df678a353c3d488a292aea5eaf4f7e82`. + +- macOS 27 ARM64: lint, all 16 backend/MCP tests, all five source GUI flows, + ZIP build, packaged MCP, and all five packaged GUI flows passed. +- Windows 11 build 26200 x64: lint, all 16 backend/MCP tests, all five source GUI + flows, NSIS build, packaged MCP, and all five packaged GUI flows passed. +- [Three-platform CI](https://github.com/rafiki270/ChatterBox/actions/runs/36272604762) + passed on that revision. Linux runs its four existing GUI flows and skips the + macOS/Windows-only tray flow; normal Linux window behavior is unchanged. + +The additional lifecycle flow enables tray mode, verifies a hidden window and +(on macOS) hidden Dock entry, reads the real board service while hidden, shows +the window, closes it without destroying it, and quits normally. Relaunch restores +the hidden mode. Disabling restores the window and Dock; another launch opens +normally. A malformed preference also opens in normal window mode. + +Tests use isolated data directories and no model calls. Native menu actions invoke the production callbacks; Quit exercises Electron's normal app lifecycle. -Windows and packaged verification for this change are pending. +The rebuilt Mac application was also launched outside Playwright: its native +ChatterBox menu exposed the option, toggling wrote the expected local preference, +and native Quit followed by relaunch returned to a connected board. The app was +left in normal window mode so the user can choose the preference themselves. +Signing and installer installation remain unverified. ### Shared boards and desktop navigation (2026-09-26)