---
title: Tray and window
---

# Tray and window

## What it is

Omniscio runs as a desktop app with a single main window plus a **system tray icon**. The tray icon (Windows: bottom-right corner near the clock; macOS: top menu bar; Linux: indicator area) is the persistent presence — it stays alive even when the window is hidden, and it's the only way to **fully quit** Omniscio once `closeToTray` is enabled, because in that mode the X button on the window just hides it instead of quitting.

The tray icon shows the Omniscio logo. Clicking it shows / focuses the main window. Right-clicking opens a small context menu with a few options. The tray's tooltip reads "Omniscio."

Two close behaviors exist, controlled by the **`closeToTray`** setting (defaults to `false`):

- **`closeToTray: false` (default)**: Clicking the window's X button quits Omniscio entirely — **always, with no exceptions**. Any other windows you have open (a detached session, a project or integration pop-out, KMS, Writer, Scratchpad, a SuperMail thread, Job Monitor or Support Chat) close with the app, and automatically reopen at their last size and position the next time you launch. Closing quits: child Claude CLI processes are sent a graceful kill, anything still running after 5 seconds is force-killed, then the app exits. **Closed means closed**: sessions that were running at quit time stay ended on the next launch — they do not auto-resume with a "Please continue" prompt. The shutdown writes a `Session suspended — app was closed` marker into each affected session's history so you can see what was interrupted, but nothing is automatically re-sent to Claude.
- **`closeToTray: true`**: Clicking X just **hides** the window. The tray icon stays visible, every Claude CLI session keeps running in the background, notifications still fire, and you can bring the window back via the tray icon or the **global hotkey**. To actually exit, right-click the tray icon and pick **Quit** (which also follows the "closed means closed" contract).

The default `closeToTray: false` matches what most desktop users expect ("close means quit"). Enable `closeToTray: true` if you treat Omniscio as an always-on assistant — sessions keep working overnight, you get notifications, and the window comes back instantly when you need it.

A **global hotkey** (default **Ctrl+Shift+M**, macOS: **Cmd+Shift+M**) brings the window to the front from anywhere on your system, even when Omniscio is hidden to tray, minimized, or behind other windows. Rebind it under **Settings → Keyboard Shortcuts → OS-level hotkeys** (or the legacy mirror at Settings → System → Global Hotkey — both write the same field). A second global hotkey (**Ctrl+Space** by default) brings the window to the front AND launches a new session in the current project.

## Where to find it

The tray icon lives in your operating system's notification area — the bottom-right corner near the clock on Windows, the top menu bar on macOS, the indicator area on Linux. Clicking it shows or focuses the main window; right-clicking it opens the tray menu with **Show Omniscio**, the wake-word entry, **Hard Reload**, **Restart Omniscio…**, **Open Developer Tools**, and **Quit**. The two global hotkeys are rebound under **Settings → Keyboard Shortcuts → OS-level hotkeys**, and the sounds and OS notifications behind the badge count are configured under **Settings → Notifications**. The `closeToTray` setting lives at **Settings → System → Keep running when I close the window** ([GeneralSettings.tsx](../../src/renderer/src/features/settings/sections/general/GeneralSettings.tsx); behavior in [window.ts](../../src/main/app/window.ts)).

## How it behaves

### How to use it

1. **Click the tray icon** (Windows: bottom-right system tray; macOS: top menu bar) to show and focus the Omniscio window. If the window was minimized, it un-minimizes. If hidden to tray, it reappears in its last position.
2. **Right-click the tray icon** for the menu:
   - **Show Omniscio** — same as clicking the icon (show + focus)
   - **Pause Wake Word** / **Resume Wake Word** — only appears when you have wake-word voice activation enabled in Settings → Voice & Speech. Toggles the always-listening microphone without quitting voice entirely.
   - **Hard Reload** — Clears the renderer's HTTP cache, then reloads the window bypassing cache. Use it when the UI looks stuck or didn't pick up a change. Same effect as `POST /app/reload` on the CLI control server and the `IPC.APP_HARD_RELOAD` handler. Non-destructive: Claude CLI sessions keep running in the background, only the renderer reloads. No confirmation prompt — the reload is fast and reversible.
   - **Restart Omniscio…** — Pops a native confirmation dialog (default button: **Restart**, cancel button: **Cancel**), then relaunches Omniscio. Picks the same graceful-shutdown path as **Quit** below: every running Claude CLI session is SIGTERM'd with a 5-second grace, gets a **Session suspended — app was closed** marker, and stays ended on the next launch (no auto-resume). Use it when you've updated a setting that needs a restart, or when something in the main process looks wedged. The trailing **…** follows the platform convention for "this action needs further input."
   - **Open Developer Tools** — Opens Chrome DevTools attached to the main window in a **separate detached window**. Detached mode is deliberate — a pinned panel inside the Omniscio window is awkward when the Omniscio window is itself the thing you're debugging.
   - **Quit** — gracefully shuts down Omniscio: terminates every Claude CLI process, force-kills anything still running after 5 seconds, then exits.
3. **Press the global hotkey to focus the window from anywhere.** Default is **Ctrl+Shift+M** (Cmd+Shift+M on macOS). Works even when Omniscio is hidden to tray, behind another window, or minimized — no need to find the icon. Rebind it under **Settings → Keyboard Shortcuts → OS-level hotkeys** (the legacy Settings → System → Global Hotkey field still works and is mirrored to the same setting).
4. **Press the new-session global hotkey for instant capture.** Default is **Ctrl+Space**. Brings the window forward AND launches a new session in the current project. Rebind under **Settings → Keyboard Shortcuts → OS-level hotkeys** (legacy mirror lives at Settings → Quick Launch → Quick Launch Hotkey). If you set this to the same combination as the focus hotkey, the new-session hotkey is skipped to avoid the collision.
5. **Close the window with X.** Default behavior (`closeToTray: false`): Omniscio quits — always, even if you have other windows open. Graceful shutdown sends SIGTERM to every Claude CLI, waits up to 5 seconds, then force-kills survivors and exits; other windows (pop-outs, detached sessions, KMS / Writer / Scratchpad) close with it and reopen on the next launch. Sessions that were running at quit time are marked **Session suspended — app was closed** and **stay ended on the next launch** — there's no "Please continue" auto-resume. With `closeToTray: true`: the window hides, the tray stays, sessions keep running, and X never quits — that setting is the only way to make X leave Omniscio running.
6. **Notifications keep firing while hidden to tray.** When a session needs your attention, Omniscio plays the configured "Needs You" sound and shows an OS notification (system notification center). The app icon also shows a **badge count** that mirrors your **Inbox** — the same number shown on the in-app Inbox (every inbox item: sessions that need you, plus approvals, agent alerts, integration rows, and so on — not just sessions), on the Windows taskbar, macOS Dock, and Linux notification area (depending on DE support). Clicking the OS notification focuses Omniscio and jumps to that session. Configure or silence under **Settings → Notifications**. See [notifications-and-silence.md](notifications-and-silence.md).
7. **Quit via Ctrl+Q is not bound by default.** The reliable ways to fully quit are: tray icon → **Quit**, or (with `closeToTray: false`) close the window via X. There's also the platform-standard Alt+F4 (Windows) / Cmd+Q (macOS), which both go through the same close-handler and respect `closeToTray`.
8. **Single-instance lock.** If you launch Omniscio while it's already running (or hidden to tray), the second instance exits immediately and the existing window comes to the front. This is enforced via Electron's `requestSingleInstanceLock()` so you never end up with two Omniscio windows fighting over the same database. (That lock guards two *processes*. Until bug 7206d9d9 a fast double-click could still leave ONE process showing two Dashboard windows, because the relaunch handler is live before the first window exists and rebuilt one the startup path was already about to build — fixed by making window creation refuse to build a second.) **One exception:** if Omniscio is in the _middle of closing_ (the window vanishes instantly but cleanup keeps running for a few seconds), a relaunch in that brief window is **ignored** rather than yanking a half-closed window back — just launch again once it has finished closing, and that launch starts fresh. This keeps the shutdown and a new startup from interfering.
9. **Popped-out windows come back on relaunch.** If you have windows popped out — a detached chat session, the KMS window, a Scratchpad, a project or integration window, or Support Chat — and you quit Omniscio, they automatically reopen at their last size, position, **and maximized or fullscreen state** the next time you launch, so you don't have to re-pop them. (The same memory applies every time you reopen a pop-out, not just on relaunch — if you had it maximized or in fullscreen, it comes back the same way.) There's no setting — it just works. To keep one from coming back, simply **close it before you quit** (only the pop-outs still open at quit time are remembered). This works even after a crash or a force-exit, not just a clean quit: the open-pop-out list is saved **continuously** during the session (and force-flushed at shutdown), so a hard crash still reopens them on the next launch — the same crash-durability as Omniscio's "resume my running sessions."
10. **Single-instance lock.** If you launch Omniscio while it's already running (or hidden to tray), the second instance exits immediately, the existing window comes to the front, **and a native "Omniscio is already running" notification appears** so the relaunch doesn't read as "nothing happened / it won't launch" (especially when the existing window was already visible). This is enforced via Electron's `requestSingleInstanceLock()` so you never end up with two Omniscio windows fighting over the same database. (That lock guards two *processes*. Until bug 7206d9d9 a fast double-click could still leave ONE process showing two Dashboard windows, because the relaunch handler is live before the first window exists and rebuilt one the startup path was already about to build — fixed by making window creation refuse to build a second.) **One exception:** if Omniscio is in the _middle of closing_ (the window vanishes instantly but cleanup keeps running for a few seconds), a relaunch in that brief window is **ignored** rather than yanking a half-closed window back — just launch again once it has finished closing, and that launch starts fresh. This keeps the shutdown and a new startup from interfering.
11. **Popped-out windows come back on relaunch.** If you have windows popped out — a detached chat session, the KMS window, a Scratchpad, a project or integration window, or Support Chat — and you quit Omniscio, they automatically reopen at their last size and position the next time you launch, so you don't have to re-pop them. There's no setting — it just works. To keep one from coming back, simply **close it before you quit** (only the pop-outs still open at quit time are remembered). This works even after a crash or a force-exit, not just a clean quit: the open-pop-out list is saved **continuously** during the session (and force-flushed at shutdown), so a hard crash still reopens them on the next launch — the same crash-durability as Omniscio's "resume my running sessions."
12. **The window always opens fully on the screen it lands on.** Omniscio reopens the main window at the size and position you left it — but if that saved size is bigger than the display it opens on, it is shrunk to fit and nudged fully on-screen first. This matters most on a **monitor rotated to portrait**: a 1080×1920 panel leaves only ~830 points of usable width, so a window sized on a landscape monitor would otherwise open wider than the screen and hang off both edges — a small "boxed" window with the theme's background glow cut off past the edge. The minimum window size shrinks with the display too, so a narrow screen can always contain the whole window and you can never be left dragging a window that is physically wider than your monitor. A window you have **deliberately** stretched across two monitors is left exactly as it is. There's no setting — it just works.
13. **On a portrait monitor, the first-ever window is portrait-shaped too.** Omniscio's built-in default window size is a landscape-shaped 1200×800. On a display that is taller than it is wide, the default becomes the usable screen area instead — so a brand-new install on a rotated monitor un-maximizes to a tall window rather than a stubby one using barely half the height. This only affects a profile with **no saved window size yet** (a fresh install, or one whose saved size was corrupt); once you have moved or resized the window even once, your own size is what comes back. On any landscape or square display the default is unchanged, exactly 1200×800. One consequence worth knowing: on a fresh portrait profile the windowed size equals the screen, so un-maximizing looks like it does nothing until you resize the window yourself — that is deliberate.

## For agents

### How it works

The main window's launch bounds are resolved before the window is constructed, in `resolveInitialWindowState()` ([src/main/app/window.ts](../../src/main/app/window.ts)), which asks two separate questions in order. First, **would any of this window be visible?** — `validateWindowBounds` intersects the saved position against every connected display and discards the coordinates if none would show at least 50px, so a window left on a since-disconnected monitor gets centered instead of opening invisibly. Second, **does it actually fit the screen it is opening on?** — `pickTargetWorkArea` + `fitStateToWorkArea` shrink the window to that display's work area and nudge it inside. The fit applies only when the window sits on exactly one display (a deliberate two-monitor span is left alone), and `resolveMinimumSize` derives `minWidth`/`minHeight` from the same work area, because Electron applies its own minimum and a fixed 800×600 floor on a narrower screen would silently re-inflate the window back over the edge. All of it is pure with the display list injected — the module never imports Electron — and all of it runs before `new BrowserWindow(...)`, never in a `ready-to-show` handler.

Ahead of both questions sits the default size for a profile that has never saved one: `resolveDefaultWindowSize` returns the primary display's work area when that display is taller than it is wide, and the standard 1200×800 otherwise (a square display counts as landscape, and a zero, negative or non-finite work area falls back). Reaching that default at all depends on `isPersistedWindowState`, which is why the launch path calls `config.get('windowState')` with **no fallback argument**: `windowState` is a required `ConfigData` field that `DEFAULT_CONFIG` pre-fills with 1200×800, so the key is always present and `config.get`'s own fallback can never fire. The predicate separates a state the app actually saved — the `close` handler always writes coordinates alongside the size — from that template. Loosen it to a width/height check and the portrait default becomes dead code while every unit test still passes. See [main-window-bounds-contract.md](../../.claude/memory/contracts/main-window-bounds-contract.md).

The tray is created by `createTray()` in [src/main/index.ts](../../src/main/index.ts) — `new Tray(nativeImage)` using `resources/icon.ico` on Windows and `resources/icon.png` elsewhere. The right-click menu is built dynamically by `buildTrayMenu()` so the wake-word entry only appears when `voiceEnabled && wakeWordEnabled`. The menu template itself comes from `buildTrayMenuTemplate(opts)` in [src/main/tray-menu.ts](../../src/main/tray-menu.ts) — a pure function extracted so the layout and per-action handlers can be unit-tested without booting Electron. Tray click and the **Show Omniscio** entry both call `ensureMainWindowRestored()` and then `mainWindow?.show()` + `mainWindow?.focus()`. The restore call matters because `runtime.mainWindow` can name a DESTROYED window (renderer-crash recovery destroys and rebuilds it): `?.` guards a NULL window, not a DESTROYED one, so without it Show would throw instead of reopening the Dashboard. The **Quit** entry calls `app.quit()`, which runs the graceful shutdown chain (kill all sessions → 5-second grace → force-kill → exit).

The three recovery actions are also in `tray-menu.ts`:

- **`performHardReload(mainWindow)`** — guards on null / destroyed window, then `mainWindow.webContents.session.clearCache()` followed by `webContents.reloadIgnoringCache()`. A bare `reload()` would be SOFT and reuse cached assets — `clearCache()` first is what makes the refresh actually pick up changed bundles. Mirrors the shape of `IPC.APP_HARD_RELOAD` ([src/main/ipc/app-data-handlers.ts](../../src/main/ipc/app-data-handlers.ts)) and `POST /app/reload` ([src/main/services/cli/cli-server-app-reload-routes.ts](../../src/main/services/cli/cli-server-app-reload-routes.ts)).
- **`performRestartApp(mainWindow)`** — shows `dialog.showMessageBox` with `{type: 'question', buttons: ['Cancel', 'Restart'], defaultId: 1, cancelId: 0, message: 'Restart Omniscio?', detail: 'Running Claude sessions will be stopped and will not auto-resume on the next launch.'}`, modal-attached to the main window when it's still alive (options-only form otherwise). Only on `response === 1` does it call `app.relaunch()` + `app.quit()`. Uses `app.quit()` (graceful) deliberately — not `app.exit(0)` — so child Claude CLIs receive SIGTERM with the standard 5-second grace, matching the tray Quit and `closeToTray: false` close-X contract. The GPU-restart route in [cli-server-gpu-routes.ts](../../src/main/services/cli/cli-server-gpu-routes.ts) deliberately uses `app.exit(0)` instead (immediate) — different contract.
- **`openMainDevTools(mainWindow)`** — guards on null / destroyed, then `mainWindow.webContents.openDevTools({ mode: 'detach' })`.

Window-close behavior is in `mainWindow.on('close', ...)` in the same file: if a graceful shutdown is already in progress (tray Quit), the close passes through; otherwise `closeToTray` decides — `true` calls `e.preventDefault()` + `mainWindow.hide()`; `false` calls `e.preventDefault()` + `app.quit()` **unconditionally**. It used to bail out and leave the app running whenever another real user window was visible (bug 7206d9d9 request 2, retired 2026-09-22): the Dashboard vanished, nothing was left on screen, and the app sat in the tray — indistinguishable from `closeToTray: true`, i.e. the setting lied. Anything meant to outlive X belongs behind `closeToTray: true`; the windows it used to protect are protected instead by pop-out restore, which reopens them on the next launch. See [main-window-lifecycle.ts](../../src/main/app/main-window-lifecycle.ts), [single-instance-liveness-contract.md](../../.claude/memory/contracts/single-instance-liveness-contract.md) I13, and `tests/unit/app/main-window-lifecycle.test.ts` (the source-level guard that keeps a quit-blocking branch from coming back). The setting defaults to `false` in `DEFAULT_SETTINGS` ([src/shared/types.ts](../../src/shared/types.ts)) and is exposed at **Settings → System → Keep running when I close the window** ([GeneralSettings.tsx](../../src/renderer/src/features/settings/sections/general/GeneralSettings.tsx)). See the [shutdown-flow postmortem](../../.claude/memory/postmortems/shutdown-flow-postmortem.md) for the ordering rules.

**Pop-out window restore** reuses that same shutdown path. At the very start of `gracefulShutdown()` — before any window is torn down — `snapshotAndPersistPopoutsForShutdown()` ([src/main/services/popout-window-restore.ts](../../src/main/services/popout-window-restore.ts)) reads the live window registry, saves the list of open pop-outs (the content roles `detached` / `kms` / `scratchpad` / `project-window` / `support-chat` / `writer`) into `config.json`, and **flushes synchronously** so a force-exit that overruns the later teardown can't lose it. That list is ALSO persisted **continuously** during the session (on every pop-out open/close), so — like `sessionsToResumeOnRestart` + the DB crash-recovery that let running sessions survive a crash — the pop-outs survive a hard crash/freeze too, not only a clean quit. On the next launch a deferred startup task — `restorePopoutWindows()`, registered in [src/main/startup/registry.ts](../../src/main/startup/registry.ts) — reads that list and, guarded by a small persisted attempt counter (incremented + flushed before reopening, reset after a successful reopen, so a crash mid-restore can no longer erase the list — the old read-once eager clear was exactly that bug — while a pop-out that crashes the app on open is still dropped after 3 tries), reopens each window by calling the **same opener** the manual pop-out uses (`openDetachedSessionWindow`, `kmsWindow.open`, `scratchpadWindow.open`, `openProjectWindow`, `supportChatWindow.open`). Because each opener already restores its own geometry from the shared `detached_window_positions` table (including the **maximized and fullscreen** flags, re-applied before the window is shown via the shared `window-geometry-persist` helpers — see [breakout-window-geometry-contract.md](../../.claude/memory/contracts/breakout-window-geometry-contract.md)) and adopts the theme on mount, size / position / maximized or fullscreen state / theming all come back for free. A detached session whose session was deleted is skipped, a project that no longer resolves is skipped (the opener throws and is caught), one failure never aborts the rest, and `AMC_DISABLE_POPOUT_RESTORE=1` turns the whole thing off. The main window, the Quick Launch overlay, the Job Monitor HUD, and the in-window embedded session view are deliberately NOT restored (they're created normally or are transient). One extra shutdown step keeps a popped-out KMS from showing up twice: KMS is a dedicated-window integration that also coexists as an in-app view, so if KMS is popped out **and** the main window was left on the in-app KMS, the persisted last-active project is reset to the Inbox (`correctLastActiveForPoppedOutKms` → `updateSettings`, best-effort). The next launch then opens the **main window on the Inbox** while the KMS pop-out reopens on its own — no duplicate KMS — and every other view still reopens where you left off (`PWR11`). See [popout-window-restore-contract.md](../../.claude/memory/contracts/popout-window-restore-contract.md).

Global hotkeys are registered in `registerGlobalHotkeys()` via Electron's `globalShortcut` API. The focus hotkey reads `settings.globalHotkey` (default `'CommandOrControl+Shift+M'`) and shows + focuses the window. The Quick Launch hotkey reads `settings.quickLaunchHotkey` (default `'CommandOrControl+Space'`) and toggles the Quick Launch floating composer window; it's gated by `settings.quickLaunchHotkeyEnabled` (default `true`) and rebindable under Settings → System. A third global hotkey reads `settings.jarvisBriefingHotkey` (default `'CommandOrControl+Shift+J'`) and emits `JARVIS_BRIEFING_HOTKEY`; it's gated by `settings.jarvisBriefingHotkeyEnabled` (default `true`) and rebindable under Settings → Voice. Single-instance enforcement lives in `enforceSingleInstance()` and forwards the second-launch's command-line args to the existing instance so deep links still work. Both reopen paths (`second-instance` on Windows/Linux, `open-url` on macOS) route through one shared handler (`makeReopenHandler` in [src/main/app/reopen-during-shutdown.ts](../../src/main/app/reopen-during-shutdown.ts)) that **ignores the reopen while `runtime.shutdownInProgress` is set** — the dying instance must not foreground a window that's about to be destroyed or dispatch deep-link work mid-teardown. See [reopen-during-shutdown-contract.md](../../.claude/memory/contracts/reopen-during-shutdown-contract.md). On a **plain** duplicate launch (a `second-instance` event carrying NO deep-link action), `dispatchDeepLink` also fires `showAlreadyRunningNotification()` — gated by the pure, unit-tested `shouldNotifyAlreadyRunning(hasDeepLinkAction, origin)` in [single-instance-policy.ts](../../src/main/app/single-instance-policy.ts) so a real `omniscio://…` relaunch and the macOS `open-url` path stay silent (the action owns its own UX).

The CLI control server has a separate `/focus` endpoint at `127.0.0.1:19519/focus` ([cli-server.ts](../../src/main/services/cli/cli-server.ts)) that just calls `focusWindow()` — this is how external tools (the omniscio-control skill, scripts, voice helpers) bring Omniscio to the foreground without needing a global hotkey. It's a read-only endpoint and safe from any context. App badge updates are in [notification-service.ts](../../src/main/services/notification-service.ts): the main window reports its unified inbox count (`useInboxAttentionCount`) via the desktop-only `notification:inbox-badge-report` channel, `setReportedInboxCount()` stores it, and `resolveBadgeCount()` calls `app.setBadgeCount()` — mirroring the in-app Inbox rather than counting `needs_you` sessions only (the session count is only the pre-hydration fallback before the renderer first reports). The Focus Mode / Presentation Mode / `desktopIconBadgeEnabled` clamps to 0 live in the shared `badgeSurfaceSilenced()`.

## Related

The pages below cover the neighbouring surfaces:

- [keyboard-shortcuts.md](keyboard-shortcuts.md) — every hotkey (including the two OS-level ones above) is now rebindable under Settings → Keyboard Shortcuts; the old Settings → System fields remain as a mirror for backward compatibility
- [open-settings.md](open-settings.md) — Settings → System is where launch-on-startup lives (and the legacy hotkey mirrors)
- [notifications-and-silence.md](notifications-and-silence.md) — sounds, OS notifications, badge counts, and how to silence them
- [keyboard-shortcuts.md](keyboard-shortcuts.md) — full list of in-app shortcuts (Ctrl+Shift+M and Ctrl+Space are global, the rest are app-only)
- [quick-launch-modal.md](quick-launch-modal.md) — Quick Launch is the global Ctrl+Space surface; this page covers its UI, defaults, and settings
