Snooze a session (part 2)
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 conversationmessages table with metadata.kind === 'snooze-marker'. Its metadata shape is the SnoozeMarkerMetadata interface: snoozedUntil — ISO timestamp the snooze ends.
What it is
This is part 2 of the Snooze a session 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 — 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.nullwhen no agent message exists yet (legacy or pre-first-response sessions).returnedAt— ISO timestamp the snooze ended.nullwhile active; populated when the marker has flipped to "Returned from snooze".
Snooze path (snoozeSessionService in /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 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) 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), 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): 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. 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): hoistSnoozeMarkers(messages) is a pure helper called from a useMemo in both MessageList and VirtualMessageList. 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): pickScrollTargetMessageId(messages, lastAgentMessageId) is a pure helper called from a useMemo in 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 "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 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 (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 — 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). Preset labels and the parser live in /src/shared/snooze-presets.ts and /src/shared/snooze-time-parser.ts; the palette UI is /src/renderer/src/components/ui/SnoozePalette.tsx and the overflow menu entry is in /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 (expandCustomSnoozePattern / validateCustomSnoozePattern); the editor is /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 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), 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_DAYSmaturity 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 infilterSnoozePresetsByDisuse/isSnoozeHistoryMature/nearestUpcomingRelevantWindowPreset(/src/shared/snooze-presets.ts). - "What it's learned" viewer. A read-only panel (/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 (
buildSnoozeKeptDroppedover 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). 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); 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); 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.
Related
The overview, the other parts, and everything else worth reading next all sit on Snooze a session.
Last verified 2026-10-05