---
title: Snooze a session (part 2)
---
# Snooze a session (part 2)

## What it is

This is part 2 of the [Snooze a session](snooze-a-session.md) page. It carries the next stretch of the material on that page, moved here because a single page is capped at 40,000 characters.

## Where to find it

Reach this part through [Snooze a session](snooze-a-session.md) — it lists every part and explains where the feature lives in the product. Everything below is reached from the same place.

## How it behaves

Everything below is the behaviour, detail and edge cases that belong to this stretch of the Snooze a session page.

### How it works

The `snoozedUntil` field on `Session` (an ISO timestamp or `null`) is the single source of truth for the snoozed/active flip. The chat marker is implemented as a **`system` message in the `conversation_messages` table** with `metadata.kind === 'snooze-marker'`. Its metadata shape is the `SnoozeMarkerMetadata` interface:

- `snoozedUntil` — ISO timestamp the snooze ends. Immutable after insert.
- `note?` — optional plain-text note, ≤500 chars, newlines preserved. Immutable after insert.
- `anchorMessageId` — id of the agent message the marker should hoist above. `null` when no agent message exists yet (legacy or pre-first-response sessions).
- `returnedAt` — ISO timestamp the snooze ended. `null` while active; populated when the marker has flipped to "Returned from snooze".

**Snooze path** (`snoozeSessionService` in [/src/main/services/session/session-lifecycle-service.ts](/src/main/services/session/session-lifecycle-service.ts)): it first reads the latest agent message id via `getLatestAgentMessageIdAsync` (off the UI thread — the sync `getLatestAgentMessageId` twin is retained as the tested-SSOT) and any prior active marker via `findActiveSnoozeMarker`, then inside one SQLite transaction it (1) mutates a prior marker to its "Returned" form (re-snooze case), (2) inserts the new marker as a `system` `conversation_messages` row carrying the metadata above (anchored to that agent id), and (3) writes `sessions.snoozed_until`. After commit, it pushes `SESSION_OUTPUT` (with the new metadata so the renderer cache hydrates the marker) and `SESSION_STATUS_CHANGED`. Pushes never run inside the transaction, so a partial DB failure can never leave the renderer with a marker the DB doesn't have.

**Past-target grace.** Before writing, `snoozeSessionService` validates the target. A malformed date is rejected ("Invalid snooze date format"). A target already in the past is normally rejected ("Snooze time must be in the future") — but one only _slightly_ past (within a 3-minute grace: a delayed mobile retry of a short snooze, or minor clock skew between phone and desktop) is **clamped forward** to about a minute from now instead of failing, so the snooze still does what you meant. The clamped time (`effectiveSnoozedUntil`) is what gets stored and shown on the marker.

**Mobile transport resilience.** On mobile (the web UI over the network), `ipc.invoke` rides a WebSocket that can briefly drop and reconnect. `session:snooze` and `session:unsnooze` are enrolled in the transport's replay set ([/src/renderer/src/lib/ipc.ts](/src/renderer/src/lib/ipc.ts) `REPLAYABLE_CHANNELS`): a snooze tapped during a blip is held and automatically re-sent when the socket reopens, instead of dying with a "Failed to snooze" toast. To keep that safe, the handlers dedup by a per-request `clientRequestId` ([/src/main/ipc/request-dedup.ts](/src/main/ipc/request-dedup.ts)) so a replay whose original already committed returns the original result rather than inserting a SECOND marker — preserving the one-marker-per-episode rule. That dedup record is persisted in a small SQLite table ([/src/main/db/queries-request-dedup.ts](/src/main/db/queries-request-dedup.ts)), so the guarantee even survives an app restart between the original snooze and the replay — a fresh process still recognizes the replay instead of re-snoozing. The Electron desktop path has no flaky socket, doesn't replay, and is unaffected.

**Expiry path** (`checkExpiredSnoozes` in [/src/main/services/snooze-service.ts](/src/main/services/snooze-service.ts)): a 60-second `createPeriodicTask` calls `listExpiredSnoozes()`. For each expired session, inside a transaction it clears `snoozed_until` **and resurfaces the session so it actually returns to the inbox** — snooze is "remind me later", so a reminder you can't see is no reminder. A session snoozed while in a **hidden settled** status (`paused` / `ended` / `archived`) is flipped to `needs_you` (archived via `unarchiveSession` for its archive-column cleanup, `paused`/`ended` via `resurfaceSnoozedSessionToNeedsYou`), because the inbox shows only attention statuses (`needs_you`/`error`/`stalled`) — without this the snooze clears but the session stays invisible (the disappearance bug this fixes). Attention statuses are already visible and stay as-is; live statuses (`running`/`ready`/…) are already shown in the sidebar's Live section and are left untouched. See [archived-session-resurrection-contract.md](/.claude/memory/contracts/archived-session-resurrection-contract.md). It then calls `findActiveSnoozeMarker`. If a marker exists, `updateMessageContentAndMetadata` flips its content to `RETURNED_FROM_SNOOZE_LABEL` and stamps `returnedAt`; the SESSION_OUTPUT push includes the updated metadata so the renderer's cache merges `returnedAt: null → ISO` without a refetch. If no active marker exists (legacy session), a fresh `Returned from snooze` system message is inserted in chronological position via `addMessage`.

**Manual unsnooze path** (`unsnoozeSessionService` in the same lifecycle file): identical mutate-in-place logic to the expiry path. The function takes a `reason: 'manual' | 'undo'` discriminator that is currently reserved — both values produce the same in-place mutation in v1. The plan reserved `'undo'` for a future "delete the marker entirely" semantics, but that requires a `queries.deleteMessage` helper and a `SESSION_OUTPUT_DELETED` IPC channel that don't exist yet. Until they land, the v1 fallback mutates on undo too — leaving a "Returned" breadcrumb rather than vanishing.

**Renderer hoist** ([/src/renderer/src/lib/snooze-marker-hoist.ts](/src/renderer/src/lib/snooze-marker-hoist.ts)): `hoistSnoozeMarkers(messages)` is a pure helper called from a `useMemo` in both [`MessageList`](/src/renderer/src/features/sessions/MessageList.tsx) and [`VirtualMessageList`](/src/renderer/src/features/sessions/VirtualMessageList.tsx). It returns a new array with each marker re-positioned directly above its `anchorMessageId`. Markers without an anchor, with a missing anchor (the anchor message was deleted), or with self-anchor stay put at their chronological position — so legacy snoozes and edge cases never lose their marker. Stable: non-marker order is preserved; multiple markers each move independently.

**Renderer scroll target** ([/src/renderer/src/lib/scroll-target.ts](/src/renderer/src/lib/scroll-target.ts)): `pickScrollTargetMessageId(messages, lastAgentMessageId)` is a pure helper called from a `useMemo` in [`useSessionPanel/useSessionConversationTurns.ts`](/src/renderer/src/features/sessions/useSessionPanel/useSessionConversationTurns.ts). It walks the post-hoist message array back-to-front looking for a snooze marker whose `metadata.anchorMessageId === lastAgentMessageId`; if found, it returns the marker's id; otherwise it defers to the helper's second precedence layer (a trailing **pending-question** anchor) and ultimately to `lastAgentMessageId` — see [scroll-position-memory.md](scroll-position-memory.md) "How it interacts with the existing scroll rules". The snooze branch documented here is unchanged and still takes precedence. The result is exposed as `scrollTargetMessageId` and plumbed into both `MessageList` and `VirtualMessageList` alongside `lastAgentMessageId`. The list components use the two ids for **different things** — `lastAgentMessageId` drives the `data-last-agent-message="true"` attribute (consumed by `useKeyboardShortcuts` for the "open first link in latest reply" shortcut), and `scrollTargetMessageId` drives the `lastAgentMessageRef` attachment (`MessageBubble`'s snooze-marker branch forwards it onto the divider element via `summaryRef`) plus the virtual-list rangeExtractor pin. `useSessionScroll` itself is unchanged: it still calls `position()` against `targetRef.current`, which is now the marker's outer divider rather than the agent's bubble. Green sessions are unaffected — the green branch in `position()` short-circuits to `scrollHeight - clientHeight` without consulting the target ref.

**Marker rendering** lives in [`MessageBubble`](/src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx) under an `isSnoozeMarker` branch — amber divider colors, alarm clock vs plain clock icon based on `returnedAt`, optional `Note: ...` block beneath with `whitespace-pre-wrap` so newlines render verbatim. The shared label and content formatter are in [/src/shared/snooze-marker-format.ts](/src/shared/snooze-marker-format.ts) (`formatSnoozeMarkerContent` for "Snoozed until X", `RETURNED_FROM_SNOOZE_LABEL` for the post-flip text).

Sidebar and inbox code everywhere excludes snoozed + scheduled sessions — a project-wide rule: **ANY attention/regular-list filter must exclude snoozed (`snoozedUntil`) + scheduled (`scheduledResponse`) sessions.** These checks route through two shared, future-aware predicates — `isSessionSnoozed(session)` / `isSessionScheduled(session)` in [/src/shared/session-attention.ts](/src/shared/session-attention.ts) — rather than reading the raw column inline. `isSessionSnoozed` is true only when `snoozedUntil` is non-null **and still in the future**, so an expired-but-uncleared snooze surfaces immediately instead of waiting for the 60-second expiry checker above; the canonical `sessionNeedsAttention` rule embeds both (see [needs-you-visibility-contract.md](/.claude/memory/contracts/needs-you-visibility-contract.md)). Preset labels and the parser live in [/src/shared/snooze-presets.ts](/src/shared/snooze-presets.ts) and [/src/shared/snooze-time-parser.ts](/src/shared/snooze-time-parser.ts); the palette UI is [/src/renderer/src/components/ui/SnoozePalette.tsx](/src/renderer/src/components/ui/SnoozePalette.tsx) and the overflow menu entry is in [/src/renderer/src/features/sessions/SessionOverflowMenu.tsx](/src/renderer/src/features/sessions/SessionOverflowMenu.tsx).

### Custom snooze shortcuts

You can teach the snooze box your **own shorthand**. A custom shortcut is two things you set up in **Settings → Sessions → "Custom snooze shortcuts"**: a **pattern** (a regular expression) and an **"expands to"** phrase, where `$1`, `$2`… drop in whatever the pattern captured. When you type something matching your pattern in any snooze box, Omniscio expands it and resolves the result with the normal parser described above — so your shortcut inherits all the smart AM/PM, DST, calendar handling, and the live preview for free; no new date logic is involved.

**Example.** Add a rule with pattern `nxwk(\d+)` and expands-to `next monday $1am`. Now typing `nxwk8` becomes `next monday 8am` → next Monday at 8:00 AM; `nxwk9` → 9 AM. One rule covers every hour, because the captured number (`8` / `9`) flows into the expansion. It's fully yours to configure — Omniscio ships the engine, not the specific shortcuts.

**A live tester** sits under the list: type a sample (e.g. `nxwk8`) and it shows exactly what it expands to and the concrete date/time it lands on, so you can confirm a rule works before relying on it. Each rule has an on/off toggle (keep it but pause it) and a delete.

**How matching behaves:**

- **Whole-input, case-insensitive.** A shortcut fires only when it matches the _entire_ thing you typed (`nxwk8`), never a fragment mid-typing — so normal snooze phrases (`5m`, `tomorrow`, `friday 9am`) are never hijacked. It's case-insensitive and folds accidental "fancy" digits like the rest of the parser.
- **It only wins when it resolves.** If a shortcut's expansion doesn't parse to a real time (say a rule produced `next monday 14am`, an impossible clock), Omniscio quietly ignores it and parses what you actually typed instead — a sloppy rule can never black-hole your normal snoozing.
- **Where it applies:** every snooze box — session, inbox item, and task snooze — shares the same picker, so a shortcut works in all of them. The calendar event box and alarms are not covered (they read different kinds of text).

**Safety.** A hand-written regular expression _can_ be pathological (the "catastrophic backtracking" class that freezes an app). Omniscio validates each rule when you save it — an unparseable or dangerous pattern is rejected outright with an inline message, so a rule that could hang the app can neither be stored nor run. This is enforced at the settings layer, so it holds whether you add a rule from the UI or the CLI. Custom shortcuts are your own local settings, so they only ever affect you. Parser + validator live in [/src/shared/snooze-custom-pattern.ts](/src/shared/snooze-custom-pattern.ts) (`expandCustomSnoozePattern` / `validateCustomSnoozePattern`); the editor is [/src/renderer/src/features/settings/sections/session/CustomSnoozeShortcuts.tsx](/src/renderer/src/features/settings/sections/session/CustomSnoozeShortcuts.tsx).

### Tidy snooze menu (Lab, in development — default OFF)

A Lab feature **drops the snooze durations you never pick** from the picker, so the menu stays short and the ones you use never change position. It does NOT reorder — durations always sit in their static, time-of-day order. Gated behind the **Settings → Lab** toggle "Trim unused snooze times" (`snoozeSmartOrderEnabled`, default OFF — the setting key + the `snooze-personalized-ordering` id are kept byte-stable from the feature's earlier "personalized order" design), registered in [/src/shared/unreleased-features.ts](/src/shared/unreleased-features.ts) and read ONLY through `isUnreleasedFeatureVisibleInRenderer` — never the raw flag.

> **Terminology:** this menu-tidying (the `h`-key snooze picker dropping the durations you never use) is what is informally called **"smart snoozing."** It is NOT the separate **Stuck-task helper** (`stuck-task-helper`, [stuck-task-helper.md](stuck-task-helper.md)), which notices you re-snoozing the _same_ item and offers a get-unstuck nudge — a different feature that the word "smart snoozing" does _not_ refer to here.

- **When OFF (the default)** the picker is byte-identical to the static, time-of-day order described above (first 4 relevant presets).
- **When ON**, the palette fetches your all-time snooze usage on open and **hides any preset you have NEVER picked** — but only once your snooze history is at least **30 days** old (the `SNOOZE_DISUSE_GRACE_DAYS` maturity gate), so a new or quiet profile shows the full list and never opens to a stripped menu. The durations you DO use stay in their normal positions (**never reordered**). The time-of-day relevance filter still runs first; the nearest-upcoming **"right now"** window option (This Morning / Afternoon / Evening, when one applies) is always kept even if never picked; your "last pick" shortcut still pins additively to the top (a short-lived convenience: it lasts up to 1 hour, is dropped as soon as the time it points to has passed — including a corrupt/unparseable stored time, which is never recommended — and is dropped once the local calendar day rolls over, so a relative label like "Tomorrow Morning" saved just before midnight can never linger into the next day and misread as _this_ morning); and the **4-item preset cap still applies** (disuse trims never-used durations first, then the list is capped at 4 — so the menu shows up to 4 preset times plus your last-pick shortcut). **Typing searches the full list** — a hidden duration is one keystroke away and returns to the menu the moment you pick it again. A failed/empty/young-history read degrades to the full list, never an empty menu. Pure logic lives in `filterSnoozePresetsByDisuse` / `isSnoozeHistoryMature` / `nearestUpcomingRelevantWindowPreset` ([/src/shared/snooze-presets.ts](/src/shared/snooze-presets.ts)).
- **"What it's learned" viewer.** A read-only panel ([/src/renderer/src/features/settings/SnoozeHabitsPanel.tsx](/src/renderer/src/features/settings/SnoozeHabitsPanel.tsx)) renders directly under the Lab toggle (only while the feature is visible), listing every preset in **static order** with its pick count and a **kept / unused / dropped** status (`buildSnoozeKeptDropped` over the SAME all-time data the picker uses, so the view can't drift from the live menu). No time-of-day filter, no cap — the overall-habits lens. Typed/custom times aren't counted; a failed read degrades to the empty state, never an error.

**Usage tracking (always-on, independent of the toggle).** Every snooze — session, inbox item, digest, weekly summary, email — records a `snooze.applied` row in `feature_events` via `recordSnoozeUsage` ([/src/main/services/feature-tracking.ts](/src/main/services/feature-tracking.ts)). The **session** path records the chosen preset parserKey _and_ a duration `bucket` (`under_1h | 1_to_6h | 6_to_24h | over_1d`, the "minutes vs hours vs longer" signal from [/src/shared/snooze-usage.ts](/src/shared/snooze-usage.ts)); the **other** surfaces record the bucket only (no preset). The disuse picker + its viewer read all-time per-preset counts + the oldest snooze timestamp via `getSnoozeDisuseData` → `STATS_SNOOZE_PRESET_DISUSE` ([/src/main/db/queries-feature-events.ts](/src/main/db/queries-feature-events.ts)); `feature_events` is never pruned, so "never picked" means truly never (not "not in the last 90 days"). The Stats "Top preset" card reads its own independent `computeTopItem`. There is no DB migration — it reuses the existing `feature_events` table, and tracking is fire-and-forget so it can never fail a snooze. Full invariants + locking tests: [snooze-recommendations-contract.md](/.claude/memory/contracts/snooze-recommendations-contract.md).

## Related

The overview, the other parts, and everything else worth reading next all sit on [Snooze a session](snooze-a-session.md).
