---
title: Focus mode
---

# Focus mode

## What it is

> Two distinct features share the name "Focus mode" in Omniscio. They are independent and can be used together. The **batch-alerts** feature (this page's primary subject) is the headline; the older **F-key sidebar filter** is a separate, view-only sidebar collapse described at the bottom.

**Focus Mode (batch alerts)** is a backend gate that batches noisy inbox notifications. Without Focus Mode on, every Needs-You session, SMS, daily digest, cron approval, automation approval, recipe approval, and CLI Pending row fires its own toast/sound/badge as soon as it arrives — that's interruption-per-item. With Focus Mode on, those alerts queue up and only fire as a single **batch alert** when a configurable rule trips (count threshold, time threshold, or both).

The rule shape is a (count, minutes, operator) triple, scoped per project (with a `'global'` system default). Examples:

- `count=4, minutes=null, operator=OR` → fire when 4 items have queued (no time floor).
- `count=null, minutes=30, operator=OR` → fire when the oldest queued item has waited 30 minutes.
- `count=4, minutes=30, operator=OR` → fire when EITHER condition trips first.
- `count=4, minutes=30, operator=AND` → fire only when BOTH are met (4+ items AND oldest is 30+ min old).

While Focus Mode is on, the amber **Needs You** dot in the sidebar and inbox is also suppressed for sessions that are _not_ whitelisted, so the sidebar visually quiets down. Sessions in pierce states (`error`, `auth_error`, `api_error`) always bypass the gate and fire individually — Focus Mode never silences a real failure. An auto-recovering session — a `rate_limited` one, or a `suspended` one being auto-resumed after an app restart (while your auto-resume setting is on) — is the opposite case: it's suppressed on **every** surface (Focus Mode or not) because it resolves itself without you — see [session-stuck-in-needs-you.md](session-stuck-in-needs-you.md).

## Where to find it

### How to use it

### Turn it on / off

There is a bell-icon pill in the top toolbar. It is a **pinnable toolbar item** (pinned by default) — like every other toolbar icon you can right-click it to unpin, move it into the "…" overflow menu, or reorder it, so its exact position is whatever you've set it to rather than a fixed slot:

- **Off**: outline `Bell` icon. Tooltip "Focus Mode (off)".
- **Armed**: filled `BellMinus` icon in the accent color. Tooltip "Focus Mode — armed, alerts hidden".
- **Tripped**: filled `BellRing` icon in amber. Tooltip "Focus Mode — items released until inbox clears".

Click the pill to toggle. The same toggle is mirrored at **Settings → Notifications → Focus Mode** (block 1), and voice commands can flip it too.

On a phone there is no pill: the **tools menu** (grid icon ▦ on the mobile tab bar) has a **Focus Mode** row. Because nothing on the phone shows the state once the menu closes, the row confirms with a short **"Focus Mode on"** / **"Focus Mode off"** message, or **"Could not change Focus Mode"** when the change did not go through.

When Focus Mode flips on, Omniscio stamps a per-project "starting line" (an ISO timestamp called `startedAt`) and seeds a per-project last-batch cookie for every project that already has queued items. **Pre-existing items do not count toward the next batch** — only items that arrive AFTER you turned Focus Mode on contribute to the count and oldest-age computation. This is so flipping the toggle on doesn't immediately fire a batch for everything that was already sitting in your inbox.

### Edit the rule from the header (popover)

The bell pill has a small chevron next to it (`ChevronDown` icon, `aria-label="Focus Mode settings"`). Two gestures open a `FocusModePopover` anchored under the bell, so you can edit the rule without leaving the dashboard:

- **Long-press the bell** (touch surfaces) — 500 ms hold opens the popover; the synthetic click that follows touchend is suppressed so Focus Mode doesn't toggle on release.
- **Click the chevron** — left-click. The chevron rotates 180° while the popover is open (`aria-expanded={popoverOpen}`).

Left-click on the bell itself still toggles Focus Mode on/off — preserved unchanged. **Right-click is intentionally NOT a popover gesture.** Because the bell is now a pinnable toolbar item, right-clicking it opens Omniscio's toolbar item-config menu (pin / unpin / move to the "…" overflow / reorder) — the same context menu every toolbar icon shares, and the only way to unpin Focus Mode. Use the chevron or a long-press to reach the rule popover.

The popover contains the same controls as **Settings → Notifications → Focus Mode** in compact form:

1. **Master enable toggle** — same as the bell pill / Settings.
2. **Hard-kill switch** ("Enable Focus Mode feature") — when off, hides the bell pill and short-circuits the service. Same as Settings.
3. **Global rule editor** — count threshold + minutes threshold + AND/OR operator, debounce-saving 500 ms after the last change. Same validation rules as Settings.
4. **Footer link** — "Open full settings →" deep-links to **Settings → Notifications → Focus Mode** for the whitelist + per-project rules.

The popover is `React.lazy()`-loaded in every caller so its weight stays out of the toolbar's entry chunk.

### Edit the rule

**Settings → Notifications → Focus Mode** has four blocks:

1. **Master enable toggle** — same as the toolbar pill.
2. **Hard-kill switch** ("Enable Focus Mode feature") — when this is **off**, the entire Focus Mode subsystem no-ops: the bell pill is hidden from the toolbar, alerts always fire individually, and the service skips evaluation. Use this if you want to permanently disable the feature without deleting your rule. Default is on. **When off, the Settings page also hides blocks 3 + 4 below** (the rule editor + whitelist — config for a disabled feature); only the two toggles remain, so config stays invisible until the feature is on.
3. **Global rule editor** — three controls:
   - **Count threshold (sessions)** — number 1–1000, or blank for "no count threshold".
   - **Time threshold (minutes)** — number 1–1440, or blank for "no time threshold".
   - **Operator** — segmented `AND` / `OR` radio. `AND` requires both thresholds to be set; `OR` requires at least one.

   Edits debounce-save 500 ms after the last change. Invalid combinations (OR with both thresholds blank, AND with either blank) display a red error and the save is held until you correct it.

4. **Whitelisted sessions** — list of sessions you've explicitly opted to surface even while Focus Mode is on. Whitelisted sessions:
   - Continue to fire alerts individually (their decision is `fire-immediately/whitelist`).
   - Continue to show the amber Needs-You dot in the sidebar/inbox.

   Each row has an inline remove (×) button that takes the session off the whitelist (via `removeSessionFromWhitelist`); the removal is reversible — re-add by pinning the session through Focus Mode again. Adding entries via "Pin a session through Focus Mode" is wired in the backend but the in-app entry point is shipping in a follow-up.

### What the batch alert looks like

When the rule trips, Omniscio emits a single toast in the **Attention alerts** category that reads `4 items ready (count)` (or `(time)` / `(count+time)` depending on which threshold fired). The batch toast bypasses the global Focus-Mode toast filter via a one-shot flag, so it surfaces even though regular attention toasts are being silenced. Click the toast to jump into the inbox and triage.

## How it behaves

### What it does NOT do

- **It does not pause sessions.** Sessions keep running, output keeps streaming. Focus Mode only changes when notifications fire.
- **It does not affect Settings → Notifications → Silence until.** That global silence still wins; Focus Mode is layered on top.
- **It does not block pierce-state alerts.** Errors, auth errors, API errors, and rate-limited sessions always fire immediately — Focus Mode never silences a real failure.
- **It is not the F-key sidebar filter.** Pressing F still toggles the older view-only sidebar filter (described below); the bell pill is a separate, persistent backend gate.
- **No per-project rule editor in v1.** You can edit the global rule. Per-project overrides are stored in the schema (`focus_mode_rules` per-row) but the UI to author them is not in v1; everything routes to the global default.

### Inbox-hide overlay (v1.5)

v1.5 layers a visible "armed" state on top of the v1 batch-alerts gate. The original v1 changed only when notifications fired — the sidebar still showed amber dots and the unified inbox still rendered every queued item, so an interruption-prone user could still be visually pulled in. v1.5 adds a sidebar-and-inbox-wide "alerts hidden" treatment that stays visually quiet until the rule trips, then lights up to amber and releases everything until the inbox empties.

### State machine

The toolbar pill, sidebar, project rows, and unified inbox all read from one store flag pair: `enabled` (you turned Focus Mode on) and `revealed` (the rule has tripped at least once and the inbox has not yet drained). The combination produces three visible states:

| State       | `enabled` | `revealed` | What the user sees                                                                                                                                                                                                                                                                                                |
| ----------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A — off     | `false`   | (n/a)      | Everything renders normally. No banner, no overlay, dots and badges visible. The pill is a plain ghost `Bell` icon.                                                                                                                                                                                               |
| B — armed   | `true`    | `false`    | Toolbar tinted accent. Pill filled accent (`BellMinus` icon, tooltip "Focus Mode — armed, alerts hidden"). Sidebar banner reads "Focus Mode — N waiting" in accent. Unified inbox is replaced by a "Focus Mode — alerts hidden / Click and hold to peek" overlay. Sidebar dots and project badges are suppressed. |
| C — tripped | `true`    | `true`     | Toolbar tinted amber. Pill filled amber (`BellRing` icon, tooltip "Focus Mode — items released until inbox clears"). Banner colours flip to amber and read about released items. The inbox overlay is unmounted, so the unified inbox renders normally. Sidebar dots and project badges return.                   |

### On-state visibility layers

When Focus Mode is on (states B and C), three coordinated tints make the state legible at a glance:

- **Toolbar tint** — a subtle background tint behind the toolbar (`bg-accent/10`-ish in armed, `bg-amber-500/10` in tripped). Same tone as the pill, just much lighter.
- **Pill bg-fill** — the [FocusModeToggle](../../src/renderer/src/features/toolbar/FocusModeToggle.tsx) pill fills with `bg-accent` while armed and `bg-amber-500` while tripped. The icon flips between three lucide bells: `Bell` (off, ghost), `BellMinus` (armed, alerts batched silently), `BellRing` (tripped, batch released).
- **Sidebar banner** — [FocusModeStatusBanner](../../src/renderer/src/features/dashboard/FocusModeStatusBanner.tsx) sits at the top of the sessions sidebar with the same accent-then-amber colour story. Armed copy is "Focus Mode — N waiting"; tripped copy describes the released-items state.

Accent for "armed and quiet" matches Omniscio's neutral-active colour. Amber for "tripped" matches the `Needs You` colour Omniscio uses everywhere else for "look at this now" — so when Focus Mode releases items, the entire stripe (toolbar, pill, banner) shifts from quiet to needs-you in lockstep.

### Inbox overlay and click-and-hold peek

In state B, the unified inbox section is replaced by [FocusModeInboxOverlay](../../src/renderer/src/features/dashboard/FocusModeInboxOverlay.tsx) — a vertically-centered `EmptyState` (wrapped in `h-full flex items-center justify-center`) with a `BellMinus` icon in the accent colour and an accent-coloured "Focus Mode — alerts hidden" title, plus the description "Click and hold to peek". The queued items still exist in the store and still render behind the overlay; they're just covered by the overlay's surface.

Pressing and holding (mouse down, finger down) on the overlay temporarily swaps the EmptyState branch out (CSS `hidden`, removing it from layout) and the held branch wraps the queued items in a `block` container, letting the user peek at what's behind it. Releasing the press returns the overlay. **Peek is CSS-only — `revealed` does NOT flip during the press**, so peeking does not start the "released until empty" cycle. The user can sneak a look without committing the inbox to its loud state.

In state C the overlay is not mounted at all. The user has already been interrupted by the batch-alert toast at this point, so hiding the inbox would be working against them — items are visible normally until the inbox drains.

### Re-arm cycle

The system returns to armed automatically once the inbox empties:

1. Focus Mode is on, items queue, the rule trips. `revealed` becomes `true` (set by the renderer when it receives the `FOCUS_BATCH_FIRED` push from `focusModeService.evaluate()`'s `fire-batch` decision — see the "How it works (technical)" section above) → state C. Items are visible.
2. The user archives, snoozes, or handles each item.
3. When the unified inbox attention count reaches zero, [useFocusModeRevealReset](../../src/renderer/src/hooks/useFocusModeRevealReset.ts) resets `revealed` to `false`. State C → state B.
4. The toolbar tint, pill, and banner shift accent → amber → accent without a click. The overlay remounts.

The reset is a consumer hook reading `useInboxAttentionCount()`. It lives in the renderer hook layer rather than the store so [focus-mode-store](../../src/renderer/src/stores/focus-mode-store.ts) stays a pure CRUD producer with no cross-store reads — a producer-versus-consumer split Omniscio enforces across all approval and inbox stores.

### Sidebar suppression

While armed (state B), seven sidebar / chrome surfaces drop their alerting cues:

- **Session row dots** — [SidebarSessionRow](../../src/renderer/src/features/dashboard/SidebarSessionRow.tsx) and [UnifiedInboxRow](../../src/renderer/src/features/dashboard/UnifiedInboxRow.tsx) receive `focusSuppressDot={true}` and stop rendering the amber Needs-You dot.
- **Project count badges** — [ProjectListItem](../../src/renderer/src/features/dashboard/ProjectListItem.tsx) receives `suppressAlerts={true}` and hides its attention / running / Gmail-unread / channel-unread count badges.
- **Projects sidebar Inbox row badge** — the amber pill rendering the unified-inbox attention count next to the **Inbox** row in [ProjectsSidebar](../../src/renderer/src/features/dashboard/ProjectsSidebar.tsx) is gated on `!focusArmed`. The row's `aria-label` also drops the `, N needing attention` fragment when armed so screen-reader users see the same quiet treatment as visual users. The Inbox label, icon, and active-highlight all stay in place — only the count pill disappears.
- **Title-bar attention count** — the bold accent number to the right of the Omniscio favicon in [CustomTitlebar](../../src/renderer/src/components/ui/CustomTitlebar.tsx), plus its trailing `|` separator, are gated on `!focusArmed`. The favicon-button itself remains clickable (so the "Go to inbox" affordance is still discoverable); only the numeric count and divider hide. This number is the UNIFIED inbox count — the SAME value the Projects-sidebar Inbox badge and the Mobile tab badge show, computed once in App (`useInboxAttentionCount`) and threaded to `CustomTitlebar` as a prop, so the header can never disagree with the inbox. (2026-08-02: previously the title-bar computed its OWN sessions-only count via `countSessionsNeedingAttention`, so it under-counted whenever a non-session inbox item existed — the "header 60 vs inbox 62" bug; see [inbox-count-list-parity-contract.md](../../.claude/memory/contracts/inbox-count-list-parity-contract.md).)
- **Sessions sidebar Inbox column header count** — the `(N)` count in the "Inbox (N)" column header inside [SessionsSidebar](../../src/renderer/src/features/dashboard/SessionsSidebar.tsx) is dropped while armed (the header reads just `Inbox`). Filter and search action buttons on the right stay visible; only the parenthesised count disappears.
- **Mobile tab bar attention badge** — the amber count pill on the **Inbox** tab in [MobileTabBar](../../src/renderer/src/components/ui/MobileTabBar.tsx) is gated on `!focusArmed && attentionCount > 0`. The tab itself stays clickable so users can still navigate to the inbox; only the alerting count badge hides.
- **Mobile session + project list rows** — on the phone, [MobileSessionRow](../../src/renderer/src/features/dashboard/MobileSessionRow.tsx) receives `focusSuppressDot={true}` (hides its amber Needs-You dot) and [MobileProjectRow](../../src/renderer/src/features/dashboard/MobileProjectsList.tsx) receives `suppressAlerts={true}` (hides its attention / running / Gmail-unread / SMS-unread count badges) — the mobile mirror of the desktop `SidebarSessionRow` / `ProjectListItem` suppression above. `MobileSessionsList` / `MobileProjectsList` derive `focusArmed = enabled && !revealed` and pass it down. Wired 2026-07-06 — previously desktop-only, so an armed phone still showed dots + badges.

The active-highlight nav state stays in all cases — selected projects, selected sessions, and the inbox row's selection background still show their highlight; only the alerting cues disappear. Whitelisted sessions are a v1 concept and remain unaffected here: their pierce-decision still fires individual alerts, separate from the inbox-hide overlay.

In state C (tripped), `focusSuppressDot`, `suppressAlerts`, and the two `!focusArmed` gates above flip back to `false` — dots, badges, and counts return so the user can see what's waiting while the inbox is in "released" mode.

### Main-panel mirror

While armed AND the unified inbox is the active view, the main panel ALSO suppresses the active session — replacing it with a Focus-Mode-specific empty state instead of mounting `SessionPanel` with the previously-selected session's chat content. Without this mirror the sidebar correctly hides its inbox list (via `FocusModeInboxOverlay`) but the main panel keeps showing a needs-you session's transcript, defeating the whole "hide the alerts" intent.

The gate lives in [stores/session-navigation.ts](../../src/renderer/src/stores/session-navigation.ts) as `isActiveSessionInboxBlocked({ isInboxSelected, focusArmed, activeSession, visibilityState })`. It returns `true` (blank the main panel) when **either** focus is armed **or** the session is filtered out of the canonical inbox right now (snoozed / grace-period / overlay-pending — the existing 2026-05-02 orphan-session rule). [Dashboard.tsx](../../src/renderer/src/features/dashboard/Dashboard.tsx) calls the helper to derive `displayedActiveSession` / `displayedActiveSessionId`, which shadow the raw `activeSession` everywhere downstream.

When the main panel blanks AND `focusArmed` is true, the inbox empty-state branch in [Dashboard.tsx](../../src/renderer/src/features/dashboard/Dashboard.tsx) renders a **"Focus Mode" empty state** rather than the green "All clear" success state — a `BellMinus` icon in the accent colour, an accent-coloured "Focus Mode" title, and the description "Alerts hidden until inbox clears". Both empty-state sites in Dashboard (mobile and desktop) are gated on `focusArmed` and centered with `flex items-center justify-center h-full` so the visual matches the sidebar overlay's vertical centering. When `focusArmed` is false, the existing green "All clear" state still wins.

In state C (tripped), `focusArmed` is false, so the main panel returns to normal even before the user clicks an item.

Project views and other non-inbox surfaces are unaffected — focus armed only blanks the inbox main pane, never a project's session list. Verified by [tests/unit/stores/inbox-visibility-predicate.test.ts](../../tests/unit/stores/inbox-visibility-predicate.test.ts) "isActiveSessionInboxBlocked — contract".

### Hard-kill behaviour (unchanged)

The `focusModeFeatureEnabled` setting still acts as a hard-kill switch. When off, every state-machine path short-circuits: the toolbar pill renders nothing, the banner does not mount, the inbox overlay never appears, and `useFocusModeRevealReset` is a no-op. v1.5 layers on top of v1's existing kill switch — there is no second flag to remember.

### Where it lives in code

- Store contract — [src/renderer/src/stores/focus-mode-store.ts](../../src/renderer/src/stores/focus-mode-store.ts) (`enabled`, `revealed`, plus actions; pure CRUD)
- Reset hook — [src/renderer/src/hooks/useFocusModeRevealReset.ts](../../src/renderer/src/hooks/useFocusModeRevealReset.ts) (consumer-side cross-store read)
- Toolbar pill — [src/renderer/src/features/toolbar/FocusModeToggle.tsx](../../src/renderer/src/features/toolbar/FocusModeToggle.tsx)
- Toolbar registry entry — the `focus-mode` id in [src/renderer/src/features/toolbar/toolbar-items.ts](../../src/renderer/src/features/toolbar/toolbar-items.ts) (`visibleWhen: focusModeFeatureEnabled !== false`), rendered through the `case 'focus-mode'` branch of [ToolbarPinnedItem.tsx](../../src/renderer/src/features/toolbar/ToolbarPinnedItem.tsx). It is no longer a hardcoded chip in [AppToolbar.tsx](../../src/renderer/src/features/toolbar/AppToolbar.tsx); existing users get it spliced into their pinned list by a one-time migration ([burst-focus-pin-migration.ts](../../src/main/services/burst-focus-pin-migration.ts), gated by `burstFocusPinMigrationCompleted`).
- Header popover — [src/renderer/src/features/toolbar/FocusModePopover.tsx](../../src/renderer/src/features/toolbar/FocusModePopover.tsx) (anchored under bell, opens on long-press / chevron — right-click is reserved for the toolbar pin/unpin menu)
- Rule editor (shared) — [src/renderer/src/features/toolbar/FocusModeRuleEditor.tsx](../../src/renderer/src/features/toolbar/FocusModeRuleEditor.tsx) (count + minutes + AND/OR; consumed by both the popover and the [FocusModeSettings](../../src/renderer/src/features/settings/sections/notifications/FocusModeSettings.tsx) panel)
- Long-press hook — [src/renderer/src/hooks/useLongPress.ts](../../src/renderer/src/hooks/useLongPress.ts) (500 ms timer; suppresses synthetic click on touchend)
- Sidebar banner — [src/renderer/src/features/dashboard/FocusModeStatusBanner.tsx](../../src/renderer/src/features/dashboard/FocusModeStatusBanner.tsx)
- Inbox overlay — [src/renderer/src/features/dashboard/FocusModeInboxOverlay.tsx](../../src/renderer/src/features/dashboard/FocusModeInboxOverlay.tsx)
- Sidebar wiring — [src/renderer/src/features/dashboard/SessionsSidebar.tsx](../../src/renderer/src/features/dashboard/SessionsSidebar.tsx) and [src/renderer/src/features/dashboard/ProjectsSidebar.tsx](../../src/renderer/src/features/dashboard/ProjectsSidebar.tsx)
- Title-bar count suppression — [src/renderer/src/components/ui/CustomTitlebar.tsx](../../src/renderer/src/components/ui/CustomTitlebar.tsx) (reads `useFocusModeStore` directly with primitive selectors)
- Suppression-prop consumers — [ProjectListItem.tsx](../../src/renderer/src/features/dashboard/ProjectListItem.tsx), [SidebarSessionRow.tsx](../../src/renderer/src/features/dashboard/SidebarSessionRow.tsx), [UnifiedInboxRow.tsx](../../src/renderer/src/features/dashboard/UnifiedInboxRow.tsx)
- Main-panel mirror gate — [`isActiveSessionInboxBlocked`](../../src/renderer/src/stores/session-navigation.ts) called from [Dashboard.tsx](../../src/renderer/src/features/dashboard/Dashboard.tsx)

### See also

- [docs/plans/2026-05-07-focus-mode-inbox-hide-design.md](../plans/2026-05-07-focus-mode-inbox-hide-design.md) — full design doc for v1.5: state-machine rationale, peek-without-reveal decision, accent/amber colour split, sidebar-suppression scope.

### Pomodoro integration (auto-enable during focus blocks)

A pomodoro run can auto-flip Focus Mode on while you're in a focus block and
off during breaks — so notifications batch while you're heads-down and
release the moment a break starts. Controlled at
**Settings → Notifications → Alarms → Auto-Enable Focus Mode During Focus Blocks**
(`pomodoroAutoEnableFocusModeDefault`, default **on**) with a per-preset
tri-state override (`null` inherit / `true` always / `false` never).

**Manual-toggle protection**: if you click the bell icon during a managed
run, Pomodoro stops touching Focus Mode for the rest of that run — your
click is a deliberate statement and shouldn't get overridden on the next
phase change. The next run starts fresh.

The wiring goes both ways via an in-process change-listener (not a push):
every focus-mode state change carries a `source` field
(`FocusModeChangeSource = 'manual' | 'pomodoro' | 'startup' | 'system'` in
[src/main/services/focus-mode-service.ts](../../src/main/services/focus/focus-mode-service.ts)).
The bell-icon click chokepoint
([src/main/ipc/focus-mode-handlers.ts](../../src/main/ipc/focus-mode-handlers.ts))
toggles with the default `source: 'manual'`, and the Pomodoro service —
subscribed to `focusModeService`'s change-listener — clears
`pomodoroOwnsFocusMode` whenever it sees a change with `source !== 'pomodoro'`.
Programmatic enable/disable from the Pomodoro service itself carries
`source: 'pomodoro'`, so the service never accidentally locks itself out. Full
per-phase / pause / crash behavior:
[pomodoro.md § Focus Mode integration](pomodoro.md#focus-mode-integration-auto-silence-during-focus-blocks).

## For agents

### CLI access

**Focus Mode arm/disarm and rule CRUD are reachable from the CLI control server.**
Current state is `GET /focus-mode/state`; the arm/disarm toggle
(`FOCUS_MODE_TOGGLE`) is `POST /focus-mode/toggle`; rule list/upsert/delete
(`FOCUS_MODE_LIST_RULES` / `FOCUS_MODE_UPSERT_RULE` / `FOCUS_MODE_DELETE_RULE`)
are `GET /focus-mode/rules`, `POST /focus-mode/rules` and
`DELETE /focus-mode/rules/:id`. The session whitelist is the one part still
exposed only via Electron IPC. The unrelated
`/focus` CLI endpoint (which simply brings the Omniscio window to the foreground) is
described in [tray-and-window.md](tray-and-window.md) and is not part of Focus
Mode (batch alerts).

### How it works (technical)

**Storage.** Per-project rules live in the `focus_mode_rules` table (migration v136) — one row keyed by `project_id` with `enabled`, `count_threshold`, `time_threshold_minutes`, `operator`, plus the seeded `'global'` sentinel row. Whitelist + enabled-state + per-project last-batch cookies live on the `focusModeState` field of `AppSettings` (so they survive restart but are not in the database).

**Decision flow.** Notification-eligible items pass through `focusModeService.evaluate(item)` in [src/main/services/focus-mode-service.ts](../../src/main/services/focus/focus-mode-service.ts) before notification-service fires anything. The decision is one of:

| Decision           | Reason                          | When                                                                                                                                                                                               |
| ------------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fire-immediately` | `pierce`                        | Item is in a pierce state (auth/api/rate-limit/error).                                                                                                                                             |
| `fire-immediately` | `whitelist`                     | Session is on the user's whitelist, OR the resolved per-project focus rule has `enabled: false` (the `'whitelist'` reason is reused for the rule-disabled path so they share a downstream branch). |
| `fire-immediately` | `focus-off`                     | Focus Mode is currently off.                                                                                                                                                                       |
| `fire-immediately` | `feature-off`                   | Hard-kill switch is off.                                                                                                                                                                           |
| `fire-immediately` | `service-error`                 | Something threw inside `evaluate` (fail-open).                                                                                                                                                     |
| `suppress`         | `rule-not-met`                  | Queue is below threshold(s).                                                                                                                                                                       |
| `fire-batch`       | `count` / `time` / `count+time` | Threshold tripped — emit `FOCUS_BATCH_FIRED` push to the renderer with `{ projectId, batchSize, reason }`.                                                                                         |

**Bucketing.** Each item carries a `projectId` (or `null` → the `'global'` bucket). When no per-project rule exists for the item's project, `resolveFocusModeRule` falls back to `'global'`, and the queue/cookie aggregate on `'global'` instead of the project id. So in v1 (where only the global rule is editable), every item rolls up to the global bucket regardless of which project it came from.

**Crash safety.** When the rule trips, the per-bucket cookie (`perProjectLastBatchAt[bucket]`) is persisted **before** the FOCUS_BATCH_FIRED push is emitted. A crash between persist and emit costs at most one missed alert; persisting after emit would risk double-firing on relaunch.

**Emit is internal to `evaluate()`.** The FOCUS_BATCH_FIRED push is fired from inside `evaluate()` itself (after persist, after `trackEvent`) — it is NOT the caller's job to emit. Real-time notification paths (`notifySms`, daily-digest, approval handlers, CLI-pending) only consume the returned decision to play their chime / OS notification; they do not separately push. The periodic timer (`evaluatePending`) also relies on the internal emit. This means the renderer's `_onBatchFired` handler runs the moment any path trips a batch — without it, the chime fires but the `revealed` flag never flips, so the banner stays in armed (accent) state instead of transitioning to tripped (amber). Bug 2026-05-10 surfaced because the emit was caller-side (only `evaluatePending` emitted) and real-time paths silently skipped the renderer transition.

**Periodic timer.** A 60-second interval re-runs evaluation in case a time threshold tripped without any new arrivals. The timer is **only scheduled when at least one rule has a non-null time threshold** — pure count rules don't need the timer and skip it to save power.

**Push events.** Three push channels announce state changes to the renderer:

- `FOCUS_MODE_CHANGED` — `{ enabled, startedAt }` — fired on toggle.
- `FOCUS_RULES_CHANGED` — `{ projectId: string | null }` (currently always `null` since rule upserts/deletes aren't yet bucketed per-project) — fired on rule upsert/delete; renderer re-hydrates the full rule set.
- `FOCUS_BATCH_FIRED` — `{ projectId, batchSize, reason }` — fired when a batch trips; renderer raises the toast.

**Hard-kill behaviour.** When `focusModeFeatureEnabled === false`, every public method on the service short-circuits, the toolbar pill returns `null` (rendered nothing), and `evaluate()` returns `fire-immediately/feature-off`. Use this to fully disable the feature without losing your rule configuration.

### Banner count — authoritative on main, renderer is a thin consumer

The "Focus Mode — N waiting" banner count reflects the SERVICE's queue, NOT the inbox-tab attention badge. The two numbers differ because they're computed from different source sets and different floor rules:

| Surface              | Source set | Pre-existing-item handling | Source                                                                                                 |
| -------------------- | ---------- | -------------------------- | ------------------------------------------------------------------------------------------------------ |
| Inbox-tab badge      | 11 sources | All items count            | `useInboxAttentionCount()` in [inbox-items.ts](../../src/renderer/src/stores/inbox-items.ts)           |
| Focus banner / queue | 7 sources  | F4 floor excludes them     | `useFocusQueueMaxSize()` in [focus-queue-count.ts](../../src/renderer/src/stores/focus-queue-count.ts) |

**Source set.** The 7 focus-eligible sources are: needs-you/stalled sessions (per-project bucket), SMS unread conversations (global), unread daily digests (global), pending CLI actions (global), pending cron approvals (per-project bucket), pending automation approvals (global), and pending recipe approvals (global). The inbox tab also counts non-alerting sources (Gmail, RSS, channel-unreads, etc.) which never flow through Focus Mode's evaluation — including them in the banner would over-report and confuse the user about why their rule isn't tripping.

**Single source of truth.** `focusModeService.getQueueMaxSize()` in [src/main/services/focus-mode-service.ts](../../src/main/services/focus/focus-mode-service.ts) walks each enabled rule, calls `computeFocusQueue(rule.projectId, state)` per bucket, and returns the LARGEST size. This is the same `computeFocusQueue` the service uses for its evaluate/fire-batch decision, so the banner and the rule that trips it are reading the EXACT SAME number. (Multi-bucket semantics match the service's first-fire behaviour — the worst-case bucket is the one closest to tripping, so it's what the banner advertises. With only the global rule enabled in v1, "max across buckets" collapses to the total post-floor queue.)

**Renderer hook.** `useFocusQueueMaxSize()` in [focus-queue-count.ts](../../src/renderer/src/stores/focus-queue-count.ts) is a ~50-line shell:

1. On mount it calls `ipc.invoke(IPC.FOCUS_MODE_GET_QUEUE_COUNT, {})` and stores the returned `maxSize`.
2. It subscribes to `IPC.FOCUS_MODE_QUEUE_COUNT_CHANGED` pushes and overwrites the count on every payload.
3. IPC errors are swallowed so the banner keeps showing the last-known count rather than flickering to zero on a transient failure.

There is NO renderer-side iteration of source stores and NO renderer-side floor computation. If you add a new notification-eligible source, you only wire it into `collectAlertEligibleItems` in [src/main/services/focus-mode-queue.ts](../../src/main/services/focus/focus-mode-queue.ts) — the banner picks it up automatically through the IPC query.

**Push channel — `FOCUS_MODE_QUEUE_COUNT_CHANGED`.** The service emits this push (`{ maxSize: number }`) from every chokepoint where the count could have changed:

- `enable()` / `disable()` — seed/clear cookies shift every bucket.
- `evaluate()` after a `fire-batch` decision — cookie shift drops items below the new floor.
- `evaluatePending()` — the 60s periodic tick. Also serves as the reconciliation safety net for source-data mutations the service never observed (user resolved a needs-you session, marked an SMS read, dismissed a daily digest, etc.) — the banner converges within at most one cycle.
- `rulesChanged()` — rule upsert/delete changes which buckets exist and which are enabled.

**Drift tradeoff.** User-driven source mutations (resolving a session, archiving an SMS) are NOT individually wired to the push. Doing so would require an emit at 7+ data-source services, which the audit fix brief explicitly warned against: "STOP and report rather than sprawling across the IPC layer." The 60s periodic tick is the reconciliation rail — up to one minute of stale banner count is acceptable for an informational surface that isn't a hard gate.

## Related

A separate, older feature also called "Focus mode" is the Shift+F sidebar filter:

- **Press Shift+F** (with the dashboard focused — a chord fires even while typing, but the bare letter this used to be did not) and the hub's session list hides every session that isn't waiting on you: running, paused and interrupted sessions leave the list, while **Needs You**, pinned sessions and the Snoozed / Scheduled / Saved / Archived shelves stay. A pop-up confirms it: "Focus mode on: running sessions are hidden from the session list".
- **It is never silent.** While it's on, a banner is pinned at the top of the sessions sidebar — above the scrolling list, so it stays on screen at every scroll position — reading "Focus mode is hiding sessions that don't need you." with a **Show all** button that turns the filter off, and the **Live Sessions** heading still counts the running sessions it is hiding (so it matches the running count on the hub's row in the left rail). The button's tooltip shows the current key.
- **Press Shift+F again**, or click **Show all**, to restore the full list.
- **Why the chord and the banner.** Until 2026-09-28 this was a bare **F**, with the only readout a muted line INSIDE the list. A user pressed F by accident, read the pop-up as the toolbar bell's alert batching, and worked for days beside a hub whose list read "Live Sessions (0)" while its badge said 1. A bare letter could empty a whole hub; the filter has no control anywhere that turns it back ON, so the one thing that says a hub is filtered must not scroll away. `Ctrl+Shift+F` was not available (Team Chat's message search) and the rest of the Ctrl+Shift band belongs to OS-wide hotkeys, so the deliberate modifier is Shift.
- It's **view-only** — does not pause, archive, or batch anything. Hidden sessions still run, still receive output, still fire notifications (the new batch-alerts feature above is what gates notifications).
- **Not persisted** across app restarts. Resets to off when Omniscio starts. Lives in renderer state as the session store's `focusModeFilter`.
- Only the main hub sidebar applies it — the session lists inside integrations (KMS, SMS, Mind Maps, …) always show their running sessions.
- The keybinding id is `toggleFocusMode` in [core.keybindings.ts](../../src/shared/keybindings/core.keybindings.ts) (default `['Shift+F']`, category `global`, `passthroughInputs: false`, label "Focus mode (session list)"). Rebind via Settings → Keyboard Shortcuts. The one-handed layout preset deliberately does NOT touch it — it clears nothing about the filter, so the chord survives that layout.
- Voice commands can also flip it via the session store's `toggleFocusModeFilter()`, and so can the CLI's `POST /ui/open` with target `focus-filter`.

This older feature and the new batch-alerts feature are independent — toggling one does not affect the other. The new toolbar bell pill controls only the batch-alerts feature, and its tooltip shows no key; Shift+F controls only the sidebar filter.

Note: this is also unrelated to the CLI control server's `/focus` HTTP endpoint, which simply brings the Omniscio window to the front. That endpoint is described in [tray-and-window.md](tray-and-window.md).

- [keyboard-shortcuts.md](keyboard-shortcuts.md) — Shift+F is in the global category; rebind via Settings → Keyboard Shortcuts
- [notifications-and-silence.md](notifications-and-silence.md) — silence notifications globally instead of (or in addition to) batching them
- [inbox-overview.md](inbox-overview.md) — the inbox surfaces all the items that flow through Focus Mode's evaluation
- [tray-and-window.md](tray-and-window.md) — the unrelated `/focus` CLI endpoint that focuses the window
- [pomodoro.md](pomodoro.md) — the paired feature that drives the auto-enable hookup
