---
title: Scroll position memory
---

# Scroll position memory

## What it is

When you scroll up in a session and then leave it — by switching to another session, opening Settings, switching projects, or any other action that hides the panel — Omniscio remembers where you were. When you come back to that session, you're returned to the same scroll position you left, so you can resume reading without having to find your place again. The restore is silent: no toast, no animation, no visible cue. The chat just opens already scrolled to where you had it.

The remembered position is **not synced to disk**. It lives in memory for the lifetime of the Omniscio process and is forgotten on app restart.

## Where to find it

There is no button, menu, panel or setting for this — it is automatic. Anywhere a session's conversation is shown in the main panel, Omniscio quietly notes where you scrolled. Switch to another session, open Settings, or switch projects, and when you return the chat opens already scrolled to where you left it, with no toast, no animation and nothing to click.

Two places deliberately do not do this. Viewing Omniscio on a phone always opens a session at the latest message rather than a remembered spot, and restarting the app clears every remembered position, because they are held in memory rather than saved to disk.

## How it behaves

### When it applies

This feature targets the "frozen session" case — the one where the default scroll behavior would otherwise lose your place:

- The session was sitting on a finished agent reply when you left (no streaming in progress).
- You scrolled up to read something earlier in the conversation — a previous message, a code block, a tool result.
- You switched away (clicked a different session, opened Settings, hit a sidebar shortcut, anything that hides the panel).
- You come back to the session — same scroll position. The earlier content you were reading is still on screen.

It works the same way for the three different ways Omniscio re-shows a session: switching sessions inside the sidebar, the keep-alive panel becoming visible again after being hidden, and the case where conversation history finishes loading after a remount.

### When it does NOT apply

Six cases fall back to the default scroll rule (described below):

1. **Running or streaming session.** If the session is green (status `running` or `starting`), Omniscio always pins to the bottom so you see new tokens as they arrive. The save side actively refuses to record a snapshot while the session is green, AND a render-driven effect evicts any pre-existing snapshot whenever the session enters the green status — even if the panel is hidden and no scroll events are firing. So a snapshot from before the agent started working cannot survive into the post-completion final message, regardless of whether you scrolled, switched tabs, or just left the panel sitting hidden in the keep-alive pool. (The content-equality guard alone is not enough here: streaming text appends to the _same_ agent message id across deltas, permission-approval stdio round-trips, tool continuations, and post-result agent continuations — so the agent-message count and `lastAgentMessageId` do not change between mid-stream and the finished reply.)
2. **Bottom-pin session (paused / ended / terminating / error / stalled, plus failure-colored `needs_you`).** Like a running session, these pin to the bottom on entry, so any saved scroll position is skipped — you always land on the latest content. Settled gray statuses joined on 2026-06-24 and the failed red statuses (error / stalled) on 2026-06-25. **Archived LEFT this group on 2026-07-11** — it now reads from the TOP of its final agent message (like `ready`) rather than the bottom, but it STILL skips position memory: every open lands FRESH at that top, never resuming a prior scroll (see the read-from-top section and case 6 below). (Unlike green, they do not actively evict the snapshot; the restore is simply not consulted for these statuses.) (The orange **needs_you** status was briefly bottom-pinned WHOLESALE 2026-06-26, reverted 2026-06-27 — but as of 2026-06-30 its **failure-colored sub-states** rejoined this group: a `needs_you` session showing a warm orange/red failure / interruption dot — Rate Limited, Auth Error, API Error, Stopped, the subagent timeout, or aborted / recovery-failed / suspended — bottom-pins so the error and its **Continue** button are visible, not buried above the fold. The COOL blue/violet "agent is asking you" sub-states still read from the top — and so does the **auto-wait timeout** (2026-07-04): an auto-waited session finished a real reply and merely sat past its wait window, so it reads from the top of that reply like any normal session, NOT the bottom. See the read-from-top section below.)
3. **New message arrived while you were away.** Even on a frozen session, if a new agent reply landed during your absence — the main-thread agent message count went up, or the last agent message id changed because a queued response was answered — restore is skipped. You're returned to the default landing position so you don't miss the new content. As of 2026-06-22, when **2+** replies piled up since your last message, that default landing is the OLDEST unread one — so you read forward through the batch rather than being dropped at the newest (see "How it interacts" below). Omniscio would rather show you new material at a sensible default than dump you mid-scroll into a conversation you no longer recognize. (The fingerprint counts main-thread agent messages specifically, not all rows: that way the lite cold-mount paint — which omits some sidechain rows visible in the full view — doesn't false-invalidate the saved snapshot on every return. The same-row streaming case is still caught because new agent replies always produce a new row that the filter accepts.)
4. **App restart.** Saved positions are in-memory only. If you quit Omniscio and reopen it, every session opens at the default landing rule — there is no SQLite persistence for this. By design: scroll position is short-term context, not a long-term setting.
5. **Mobile (phone).** As of 2026-06-27, position memory is DESKTOP-only — a phone never restores a saved scroll position and always lands fresh on the latest (see the "Mobile does NOT remember your scroll" edge case below for the why). Desktop is unchanged.
6. **Archived session.** As of 2026-07-11, archived sessions ALWAYS land fresh on the top of their final agent message — position memory is never restored, so re-opening one you'd scrolled through snaps back to that top rather than resuming where you left off. This is the desktop counterpart of the mobile carve-out (case 5) and applies in BOTH viewing surfaces (the main panel and the standalone Archive view). Unlike the bottom-pin group (case 2), archived lands at the TOP, not the bottom.

### How it interacts with the existing scroll rules

Omniscio has a hardcoded scroll system — see the "Session scroll" rule in CLAUDE.md. The default rule depends on session status:

> **Single engine (2026-07-12):** the experimental chat-v2/v3 scroll engines were retired (they had always defaulted OFF, so what this page describes — the v1 behavior — is what every user has always run and still runs). Their code is preserved under attic/scroll-engines/.

- **Green sessions** (running/starting) always pin to the bottom of the viewport.
- **Bottom-pin sessions** (paused / ended / terminating, plus the failed error / stalled, plus a `needs_you` session in a warm orange/red failure / interruption sub-state — see the next bullet) ALSO pin to the bottom on entry (gray settled 2026-06-24, failed 2026-06-25, failure-colored `needs_you` 2026-06-30, all at the user's request) — they open showing the latest content, exactly like a running one, instead of the top-of-last-message landing below. They skip position-memory restore for the same reason green does (they always show the latest on entry), and they IGNORE the anchor overrides below for scroll — those only shift the read-from-top landing (`ready` and `needs_you`); the resolved target still drives the "final message" highlight. This is the default (v1) engine's behavior. (**Archived** left this group on 2026-07-11: it reads from the TOP of its final agent message like `ready`, but still skips position memory — always fresh at that top, both viewing surfaces. See case 6 above.)
- **Needs You (`needs_you`) reads from the top** (viewport-dependent, like `ready`) — for plain amber `needs_you` (no sub-state) and the COOL blue/violet "agent is asking you" sub-states (Question / Plan / Permission / Mission): it lands at the top of an overflowing message (or the bottom if it fits), on the scroll-target picker's chosen message — so the trailing-filler tier (override 4 below) lands it on a buried real reply rather than the filler. (It was briefly bottom-pinned WHOLESALE 2026-06-26 then conditionally 2026-06-27; BOTH reverted 2026-06-27 — slamming EVERY needs_you to the bottom buried these real replies. The narrower 2026-06-30 carve-out bottom-pins ONLY the warm orange/red **failure** sub-states — Rate Limited / Auth Error / API Error / Stopped / the subagent timeout / aborted / recovery-failed / suspended — which have no reply to read, just an error + a Continue button; the cool "asking" states stay here, reading from the top, as does the **auto-wait timeout** (removed from the bottom-pin set 2026-07-04 — an auto-waited session has a completed reply to read, so burying its top was the reported bug).)
- **Attention read-from-top session** (`ready` and `needs_you`) lands on the latest agent message: at the top of that message if it overflows the viewport, otherwise at the bottom of the chat. Four anchor overrides can shift the landing target off the literal latest message — none hides anything, all are best-effort (if the pick is off you just scroll a little): (1) a **snooze marker** anchored to the latest agent message becomes the landing target instead; (2) a **pending question** — when the agent asked something (a question widget, or a captured AskUserQuestion) and then a background-initiated turn landed after it (a `run_in_background` command finishing, a scheduled wake-up, a sub-agent completing), the view rests on the question rather than the trailing "X finished, confirming…" note that would otherwise bury it; (3) **skip auto-waited turns** (added 2026-06-08; refined 2026-06-09) — if the latest agent turn is PURELY a short "waiting on external work" line (the "⏱ Auto-waited" badge the waiting-detector adds when a turn ends on a build / test / background wait), the view skips back to the last REAL agent reply instead of resting on the "waiting on the build…" status. The 2026-06-09 refinement skips ONLY a turn whose firing row is a _pure_ short wait line — a substantive reply that merely ENDS with a wait line is NOT skipped (the unrefined skip was climbing past real replies, the "lands randomly" report); and (4) **oldest unread** (added 2026-06-22) — when 2+ real agent replies have piled up since your last message (the agent kept going on its own, or several turns landed while you were away), the view lands on the OLDEST of them so you read forward through the unread batch in order instead of being dropped at the newest. (2026-06-27: with just ONE unread real reply followed by trailing filler, the view lands on that reply too, not the filler below it — the same "don't strand me on filler" fix, and how a Needs You session reading from the top lands on the buried reply rather than the filler.) Filler ("waiting on the build…", "nothing to do here", short progress pings) doesn't count — and a turn carrying Omniscio's official "Waiting on a background process…" sentinel is skipped even when it's long or spans several paragraphs (2026-06-23: a short "waiting" line already didn't count; this covers the longer official form too. 2026-06-25: the skip now recognizes the sentinel by its TEXT, not only by the waiting-detector's hidden tag — so it applies even when that tag was never written: older threads, external-engine sessions, or any session where the waiting-detector wasn't running. A live read of real data found ~30 of 807 sentinel-bearing sessions had no tag at all, where the view could previously land on the waiting message). It only kicks in once you've actually sent a message in that chat (a session you never typed into keeps landing on the latest). In all four cases the landing **never climbs above your most recent reply** — and your own auto-reply rule / Inbox Pilot count as your reply for that floor, though the system's mechanical nudges (auto-continue, rate-limit retry, crash resume) do not. The snooze override still wins when several apply, and the oldest-unread override beats the pending-question one. The same resolved target also drives the green "final message" highlight, so the highlight follows the skip too.

Scroll position memory is a **permanent exception** layered on top. It runs **before** the default rule on the three restore paths (session-switch panel remount, hidden-to-visible transition, history-loaded settle). On a hit, the saved scroll offset is restored and the default rule is skipped. On a miss — no saved entry, or the content fingerprint changed — the default rule still wins.

In practice this means green sessions naturally still pin to the bottom (the save side never records while green, and any leftover entry from before streaming is evicted by a render-driven effect the moment status enters running/starting — no scroll event required), and non-green sessions only deviate from the "top of latest message" landing when you actually had a saved position from before.

### Edge cases worth knowing

- **Scrolling all the way to the very top is preserved.** The top of the chat is a meaningful position (you're reading the start of the conversation), so Omniscio remembers `scrollTop = 0` like any other offset.
- **Reaching the bottom is also remembered — and restored to the LIVE bottom on return.** When you scroll back down to bottom, Omniscio records a snapshot with an `atBottom` flag set. On the next return to this session, Omniscio pins to the current `scrollHeight - clientHeight` (the live bottom right now) instead of the literal saved `scrollTop`. This matters on long threads where the latest agent message is taller than the viewport: without the live-bottom restore, the saved `scrollTop` could be a few pixels off after layout shifts between leaving and returning, the "at bottom" predicate would read false, and the next layout pass would strand you at the top of the overflowing latest message — visually "halfway up the page" instead of where you actually left off. The fix recorded on 2026-05-10 captures the at-bottom intent at save time and replays it as a pin-to-live-bottom on restore.
- **A mid-thread spot is remembered as a MESSAGE, not a pixel (2026-08-15).** When you scroll to the middle of a conversation, Omniscio records WHICH message sits at the top of your view plus its exact offset — and on return it puts that message back there, rather than replaying a raw pixel offset. WHY: a hidden panel re-derives its layout on re-show (off-screen messages get estimated heights until you scroll near them — see the off-screen culling perf system), so the same pixel count can point at completely different content; before this fix, a mid-thread return could land you "somewhere in the middle, randomly" on desktop — the same failure that led to the mobile carve-out (case 5) and the live-bottom restore (previous bullet), finally fixed on its last leg. A short settle-hold keeps that message pinned while nearby heights re-measure, and it lets go the instant you scroll yourself. If the remembered message no longer renders (e.g. it was folded into a collapsed marker while you were away), the old pixel-based restore is used as the fallback. See [desktop-mid-thread-restore-raw-pixels-postmortem.md](../../.claude/memory/postmortems/desktop-mid-thread-restore-raw-pixels-postmortem.md).
- **Mobile does NOT remember your scroll — every open lands fresh (2026-06-27 carve-out).** Position memory is a **DESKTOP-only** affordance. On a phone, `tryRestoreScrollPosition` early-returns and `decidePrepaintRevealAction` skips the restore, so reopening a session always lands you fresh on the latest reply (read-from-top on the picker target), never where a prior visit left off. WHY: a live mobile trace showed the restore dropping the user mid-thread ("the middle") on re-entry, and the saved spots came from rapid inbox-triage / keep-alive mounts rather than deliberate scrolls — so "remembering" them was noise (user request: "in Mobile I don't think it's supposed to remember the scroll"). Both restore chokepoints are gated off on mobile. **Desktop is unchanged** — it still restores your saved position. See [session-scroll-contract.md](../../.claude/memory/contracts/session-scroll-contract.md) Invariant 3 (mobile carve-out).
- **Save fires while YOU scroll, not on close — and only on a DELIBERATE scroll (2026-07-03).** Omniscio writes the snapshot when your scroll position moves, throttled through the same `requestAnimationFrame` callback that drives the "is the user near bottom?" tracking — but ONLY when a real user gesture (wheel / a **vertical** touch / a scroll key) fired within the last few seconds. A horizontal swipe is navigation, not scrolling — it changes sessions and never counts as scroll input (2026-09-19). A scroll the browser itself triggers — a programmatic jump, a keep-alive panel mounting, the lite→full paint growing the thread, content mounting above the viewport and nudging `scrollTop` — records **nothing**. Before this, every one of those phantom scrolls saved a snapshot, and on desktop that stale spot then restored on re-entry and dropped you into the middle of the conversation (the "auto-waited session in the inbox scrolls me to the middle" report). This is the desktop complement to the 2026-06-27 mobile fix, which switched the _restore_ off entirely on phones; desktop keeps position memory but now only remembers where you actually scrolled. Closing the panel is not a special event — by then the position (if you scrolled to it) is already saved.
- **Per-session isolation.** Each session has its own saved entry, keyed by session id. Scrolling around in session A does not affect session B's saved position.

## For agents

### Where it lives in code

All of it lives in [useSessionScroll.ts](../../src/renderer/src/features/sessions/useSessionScroll.ts) and its extracted [saved-scroll-position.ts](../../src/renderer/src/features/sessions/useSessionScroll/saved-scroll-position.ts) module:

- A module-level `Map<sessionId, SavedScrollPosition>` records `{ scrollTop, messageCount, mainThreadAgentMessageCount, lastAgentMessageId, atBottom, scrollHeight, anchorMessageId?, anchorOffsetPx? }` whenever the user's scroll position is captured by the `handleScroll` rAF callback on a non-green session **AND a real user gesture fired within the last few seconds** (`userScrollGesture.hasRecentInput()` — the 2026-07-03 gate that stops a programmatic / keep-alive-mount / lite→full-paint phantom scroll from writing a stale snapshot; the same gesture signal the green branch already consults). The two anchor fields (2026-08-15) capture WHICH `data-msg-id` row sat at the container top + its signed offset (`captureTopVisibleAnchor` in [session-scroll-math.ts](../../src/renderer/src/features/sessions/session-scroll-math.ts) — an O(log n) binary search, skipped for at-bottom saves). Both at-bottom and away-from-bottom snapshots are stored, distinguished by the `atBottom` boolean. The fingerprint that decides whether a snapshot is still valid is `mainThreadAgentMessageCount` (count of main-thread, non-sidechain agent messages — stable across the lite cold-mount projection that omits some sidechain rows) plus `lastAgentMessageId` (catches the same-row streaming case); the older `messageCount` field is kept on the record for diagnostic logging only. When the session status is `running` or `starting`, the save path skips recording AND deletes any pre-existing entry — closing the case where the user scrolls midstream while the panel is visible.
- A separate **render-driven Effect 3b** (`useEffect` on `[session.status, session.id]`) deletes any saved entry whenever the session is green. This closes the case `handleScroll` cannot reach: a hidden keep-alive panel that fires no scroll events while the agent extends content under the _same_ `streamingMessageId` (permission-approval stdio round-trip, tool continuation within a turn, post-result agent continuation). Without Effect 3b, the fingerprint check would falsely match on return — the agent-message count and `lastAgentMessageId` are unchanged on both sides of the green window — and restore a `scrollTop` that predates the agent's work. Effect 3b runs on every status change, is idempotent (`Map.delete` is a no-op when no entry exists), and is decoupled from prev-status tracking so it doesn't depend on Effect 1's render-order behavior.
- `tryRestoreScrollPosition(caller, container, sessionId, currentMainThreadAgentCount, currentLastAgentId, …)` reads the map and writes `container.scrollTop` only if both fingerprints match (gated by the pure exported helper `isSavedPositionStillValid`). When the saved entry has `atBottom: true`, it writes `max(0, scrollHeight - clientHeight)` (the live bottom right now) instead of the literal saved `scrollTop`. A mid-thread entry resolves through the shared `resolveRestoreScrollTop` (2026-08-15): locate the saved anchor row and compute the scrollTop that puts it back at its saved offset (`computeAnchorRestoreScrollTop`, transform-immune rect math, clamped to the scroll range); the raw saved `scrollTop` applies only when the snapshot carries no anchor or the row no longer renders. The pre-paint seed's `restore-saved` branch uses the SAME resolver, so the first painted frame and the post-paint landing always agree. After a successful mid-thread restore, `scheduleAnchorSettleHold` (the mid-thread twin of the at-bottom settle chain) re-pins the anchor row while late-measuring content shifts around it — deciding each animation frame via the pure `resolveAnchorHoldTick`, and standing down permanently on any real user gesture. Returns `true` on success, `false` on miss or stale.
- A shared `commitRestoreSuccess()` `useCallback` mirrors the same six ref/state writes that the default `position()` function does on success — without it, the next message arrival would yank the chat back to bottom because the hook would think it was still in "pending" state.
- Three call sites invoke the helper before falling through to `position()`: **Effect 1** (session-switch panel remount), **Effect 5** (history-loaded settle when the sparse cache is replaced by IPC-fetched history), and **Effect 7** (hidden-to-visible transition, driven by the keep-alive panel's `ResizeObserver`). These are the **v1** (legacy) scroll engine's restore paths.

## Related

- [lazy-content-load.md](lazy-content-load.md) — the other layer that makes a long session paint instantly on return; lite cold-mount and scroll restore both run on the same session-switch panel-remount path.
- [snooze-a-session.md](snooze-a-session.md) — snooze markers can also affect where the chat lands when you reopen a session; the marker is a separate scroll-target override, not part of the position-memory cache.
- [waiting-detector.md](waiting-detector.md) — the "⏱ Auto-waited" system whose tagged turns the third scroll-target override (skip auto-waited) lands past, so the view rests on the last real reply.
- [keyboard-shortcuts.md](keyboard-shortcuts.md) — switching sessions with J/K is one of the things that triggers the save-and-restore round trip.
- [question-widget.md](question-widget.md) "Pinned control bar" — a different scroll-driven mechanism that's easy to confuse with this page: the Question Widget's control bar stays pinned to the top of the viewport as you scroll past a tall widget. That is a read-only, paint-only `transform` on one element; it is **not** this page's session-level `scrollTop` save/restore.
