The exclusive controller, isolated message viewport, request attribution, geometry observation,
atomic message-view commit, and bounded renderer cache are implemented. The real Electron test at
test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts uses deterministic SQLite history without a
provider. It exercises long-history pagination, bounded rendering, reading-position preservation,
Session switching, and loaded-message search. Frame-by-frame compatibility, physical input, and
reference-machine performance remain explicit gates in plan.md.
useChatScrollControlleris the only low-level message viewport writer. It enforces one active operation and at most one physical write before the next animation-frame boundary.- Passive owners are mode-gated: restore/follow cannot run in reading mode, while measurement anchoring cannot run in following mode.
- Programmatic scroll events are matched by request ID and expected position rather than timeout windows. User gestures cancel active and pending work atomically.
- Header, message viewport, and composer use isolated shell rows. One geometry observer reports viewport/message-root changes to the controller; the former eight-frame restore loop is removed.
- Message session loading keeps active selection separate from the committed message view, commits prepared records atomically, and uses a renderer-only five-session/count-and-memory-bounded LRU.
- Pending inputs load as secondary state and no longer block first message commit. Recent parsed messages and message measurements remain bounded and reusable across cached switches.
- The message store is the single readiness owner. Load, history, cache, submit, stream, pending input, and session hydration commits use request generations so session-ID ABA cycles cannot revive stale work.
- Same-session loads are fenced by live mutation revisions. Streams remain session-scoped before commit and are ordered and settled by request identity; stale terminals cannot clear newer streams and duplicate terminals cannot start duplicate refreshes.
- Unit, component, focused ownership, and deterministic real Electron smoke coverage protect the contract.
Chat scrolling must feel native and remain under the user's control in short, long, restored, and streaming conversations. Opening search, loading history, rendering Markdown, resizing the composer, showing interactions, or receiving stream updates must not flash, jump backward, or steal the scrollbar.
The redesign must preserve every current ChatPage capability and all existing user data while making the implementation smaller, testable, and maintainable.
- Make the message viewport the only element that owns chat scrolling.
- Route every programmatic scroll through one typed controller with explicit reason and priority.
- Give an active user gesture durable ownership until the user explicitly returns to the bottom or requests a navigation action.
- Preserve current short-chat, long-chat, streaming, history, search, Spotlight, capture, interaction, pending-input, read-only, and session-switch behavior.
- Keep the bounded message window and logical message addressing for long-chat performance.
- Preserve all stored sessions, messages, cursors, settings, drafts, and pending inputs without migration.
- Reduce ChatPage responsibilities into focused components and composables with black-box tests.
- Add real Chromium coverage for layout-dependent scroll behavior.
- Make first chat paint and session switching fast without trading away loaded history, search counts, or data correctness.
- No database, IPC, presenter, session schema, or message schema changes.
- No redesign of message visuals, composer visuals, search UI, or interaction UI.
- No replacement of the existing message store or provider/streaming pipeline.
- No third-party virtual-list dependency as the source of message identity or position.
- No change to the meaning or default of
autoScrollEnabled.
The page shell must use three independent layout regions:
ChatPage shell (overflow: hidden)
├── Header region fixed layout row; never scrolls with messages
├── Message viewport minmax(0, 1fr); the only overflow-y:auto owner
│ ├── history status overlay out of normal message flow
│ ├── search overlay out of normal message flow
│ └── MessageList messages and virtual spacers only
└── Composer region fixed layout row; outside message scroll geometry
├── pending input lane
├── interaction layer
├── ChatInputBox
└── ChatStatusBar
The shell uses display: grid, grid-template-rows: auto minmax(0, 1fr) auto, and
overflow: hidden. Only the message viewport exposes overflow-y: auto. These are layout regions
within ChatPage.vue; component filenames do not determine the boundary.
Header, composer, status, pending-input, and search height changes must not add or remove content inside the message scroll extent. Viewport size changes are reported to the controller as geometry changes, not mistaken for user scrolls.
All chat scroll writes must pass through useChatScrollController. Components and feature
composables may request navigation but may not write scrollTop, call scrollTo, or call
scrollIntoView on the message viewport directly.
Allowed request reasons:
type ChatScrollReason =
| 'session-restore'
| 'auto-follow'
| 'submit'
| 'history-prepend'
| 'measurement-anchor'
| 'history-navigation'
| 'search-navigation'
| 'spotlight-navigation'
| 'indicator-navigation'
| 'user-return-to-bottom'indicator-navigation is the "scroll to latest" indicator jumping to a specific message below the
viewport. Like search and Spotlight navigation it is an explicit user navigation: it starts
navigating, preserves the user's ownership across the jump, and records
hasExplicitNavigation.
Every request carries the active session epoch. Stale requests from an old session are discarded. The controller commits at most one physical scroll write per animation frame.
Only one scroll operation may be active at any time, including across multiple frames. A request cannot run concurrently with restore, follow, history, measurement, search, or Spotlight work. Repeated requests from the same owner coalesce into the active operation. A higher-priority explicit request replaces the active operation atomically. Lower-priority passive requests are discarded, not queued for a delayed write that could pull the viewport back later. A user gesture cancels the active operation and all uncommitted work in one step.
Priority is explicit and stable:
- Active user gesture or durable reading mode.
- Explicit user navigation: search, Spotlight, return-to-bottom.
- History-prepend anchor preservation.
- Session restore before any user interruption.
- Generation auto-follow when enabled and still owned.
- Passive measurement refinement.
User ownership is event-driven, not timeout-driven. Wheel, touch, scrollbar pointer, and scroll keys enter reading mode. Reading mode persists until one of these occurs:
- the user scrolls back within the bottom threshold;
- the user presses the explicit return-to-bottom action;
- the user starts an explicit search or Spotlight navigation;
- the session changes and a new session epoch begins.
Idle timers may be used to reduce measurement and rendering work, but they must never decide who owns the scrollbar.
type ChatScrollMode =
| 'restoring'
| 'following'
| 'reading'
| 'navigating'
| 'history-preserving'
type ChatScrollState = {
sessionEpoch: number
mode: ChatScrollMode
userOwned: boolean
nearBottom: boolean
activeGesture: boolean
lastCommittedRequestId: number
/** Whether ownership must be restored to the user when an explicit navigation completes. */
resumeUserOwnedAfterNavigation: boolean
/** True once the user explicitly navigated (search, Spotlight, indicator) in this session. */
hasExplicitNavigation: boolean
}Programmatic writes use request IDs and expected targets. The following scroll event is matched to
the committed request instead of being classified by a time window. Unexpected position changes are
treated as native layout/clamping events and observed separately.
- Below the existing windowing threshold, all messages remain rendered and row measurements never write scroll position.
- Above the threshold, the existing logical layout model continues to provide message top/bottom positions and bounded DOM rendering.
- Measurement batches update the logical height map once per frame.
- When reading a long conversation, one stable logical message anchor is preserved in the same frame as the height-map commit.
- Overscan and mounted-row bounds remain compatible with the current long-chat contract.
- A session begins with a new epoch and one initial bottom request.
- Late restore, resize, or message responses from an older epoch are ignored.
- The restore request is permanently cancelled by any user gesture in that epoch.
- Repeated eight-frame or broad ResizeObserver bottom writes are removed. If asynchronous content
requires settling, the controller follows only while mode remains
restoringorfollowingand coalesces to one frame write. - Resize-driven following obeys
autoScrollEnabled; restore positioning remains allowed regardless of that preference, but later message-root or composer geometry changes may not bypass it. - A layout-induced native scroll may update bottom proximity, but it may not surrender durable user ownership without an active user gesture.
Loading is split into critical and secondary paths:
critical path
session selection
-> prepare latest message window
-> atomically commit session view
-> render latest bounded rows
-> position once
-> input and message viewport interactive
secondary path
pending inputs + metadata + adjacent pre-measurement + optional pre-hydration
- Do not clear the visible message view before the target session window is ready to commit.
- Never expose a mixed state containing the new session header and old session messages.
- Uncached switches use a fixed-geometry loading overlay inside the isolated viewport. Cached switches restore a renderer-only recent-session view immediately.
- Keep a bounded in-memory LRU cache of recent session view models, logical height maps, and last viewport anchors. The cache is ephemeral, keyed by session ID plus message revision, and never persisted.
- A prepared session result commits only when its session epoch is still current.
- Selection changes advance both message-load and history generations, including A-B-A cycles; cache misses do not advance either generation.
- A failed or superseded preparation does not expose the target view or enable its composer unless a matching cached view was already committed safely.
- Message data and the latest viewport are the critical path. Pending inputs, status metadata, and optional adjacent-row hydration load in parallel but do not block first message paint.
- Load the existing history count for compatibility, pagination, and search correctness, but mount only the bounded viewport plus overscan. Loaded records must not imply mounted heavy DOM rows.
- Preserve draft and editor behavior across Session switches. Keyed page remounts restore Session-scoped view and bounded measurement state without exposing mixed-Session content.
- Submitting a new message explicitly transfers ownership to bottom-follow for that new turn.
- Streaming follows the bottom only when
autoScrollEnabledis true and the user has not entered reading mode afterward. - Token revisions do not independently write scroll position. They invalidate one coalesced auto-follow request.
- Stream snapshots are monotonic within a request. Each request settles once, terminal events are ignored when a newer same-session request owns the view, and post-terminal snapshots cannot resurrect streaming state.
- Async submit, queue, steer, and compaction continuations must still own the same page generation before mutating composer or optimistic view state.
- When the user scrolls away, streaming continues without changing the viewport.
- Coalesced search or Spotlight requests preserve the ownership state captured by the first active navigation transaction until that transaction completes.
- Pagination requires a full initial history window, a scrollable viewport, an upward user intent, and a threshold crossing owned by the controller.
- Upward intent that begins while already inside the top threshold arms pagination even when the
browser emits no additional
scrollevent. - Only one history request may exist per session epoch.
- The pre-request logical anchor and current user offset are captured.
- After prepend, the controller commits one anchor-preserving correction before paint.
- Loading chrome is overlay-only and does not change message origin or scroll extent.
- Touch ownership and frozen window measurements remain active through native inertial scrolling,
ending on
scrollendwith an idle fallback.
- Search and Spotlight resolve a
messageIdthrough the logical layout model. - Off-window navigation first requests the correct logical viewport, waits for the target row to
mount, then applies highlight without a second viewport-level
scrollIntoView. - Same-query highlight refresh never navigates.
- TipTap selection scrolling is contained inside the editor. It must not scroll the message viewport or page shell.
- Cmd/Ctrl+F result counts and navigation semantics remain unchanged.
| Capability | Required result |
|---|---|
| Session open/switch | Latest messages appear at bottom unless the user interrupts restore |
| First application chat load | Shell and input become interactive before secondary session state |
| Cached session switch | Recent session view and anchor restore without an empty-state flash |
| Uncached session switch | Fixed viewport loading state, then one atomic target-session commit |
| Auto-scroll enabled | New generation follows bottom until user scrolls away |
| Auto-scroll disabled | Generation never steals the viewport |
| New message submit | New turn moves to bottom once, then respects later user ownership |
| Short conversations | No pagination, measurement correction, flash, or rollback |
| Long conversations | Bounded mounted rows, stable spacers, no blank gaps |
| Older-history loading | Same message and intra-message offset remain visible |
| Cmd/Ctrl+F | Full loaded-message count; next/previous works across virtual windows |
| Spotlight/trace jump | Target mounts, centers once, highlights, and clears pending navigation |
| Streaming Markdown | No full-list rebuild and no viewport movement in reading mode |
| Images/artifacts/tools | Late size changes preserve long-list anchors without short-list writes |
| Interaction/pending lane | Visual behavior unchanged; no participation in message scroll extent |
| Composer resize/focus/IME | Draft and focus remain intact; message viewport is not scrolled |
| Read-only/subagent sessions | Existing display and navigation behavior remains unchanged |
| Capture/export | Full loaded-message capture behavior remains available |
- No changes under
src/main,src/preload, database schemas, encryption, import/export, or persisted configuration. - No changes to message IDs,
renderKey, ordering, pagination cursor, session identity, or message cache semantics. autoScrollEnabledretains its current persisted key and meaning.- Scroll controller state is renderer-only and ephemeral. It is reset on session epoch changes and is never persisted.
- Scroll ownership changes require no persisted-data migration.
- During an active user gesture, no unauthorized programmatic scroll write occurs.
- In reading mode, the selected logical anchor drifts by at most 1 CSS pixel across Markdown, image, tool, composer, and streaming layout changes.
- Short conversations with 1–20 messages perform zero measurement-driven scroll writes.
- One user navigation action produces at most one viewport write plus one highlight-only DOM pass.
- One animation frame contains at most one committed viewport scroll write.
- Across frames, at most one scroll transaction remains active; no second owner can alternate writes with it.
- Long conversations keep mounted heavy message rows bounded by the active window and overscan.
- Continuous wheel/trackpad scrolling has no application-generated long task above 50ms and no repeated frame-by-frame full-list conversion.
- Streaming scroll work is coalesced to animation frames; token frequency does not equal scroll write frequency.
- Search, history, and Spotlight navigation complete without visible intermediate window swaps.
- Existing renderer unit suites, focused ownership tests, typecheck, lint, and i18n pass.
- Chromium integration scenarios show no flash, rollback, native clamp, or scrollbar theft.
- On the agreed reference machine, warm cached session switches reach first meaningful message paint at p95 within 100ms, uncached local switches within 250ms, and initial chat shell interaction within 150ms. CI records these metrics and uses a wider non-regression budget where machine variance makes absolute limits unsuitable.
- Session switching performs no full heavy-DOM mount for every loaded message and produces no intermediate empty or mixed-session frame.
ChatPage.vuebecomes an orchestration shell rather than a scroll implementation owner.- Direct message viewport writes are restricted to the controller module by focused ownership tests.
- Scroll state transitions are pure and unit-testable.
- Feature composables request typed scroll intents instead of sharing mutable timers.
- Each asynchronous operation is scoped by session epoch and cancellable cleanup.
- Comments explain invariants and ownership, not timing folklore.
docs/ARCHITECTURE.mdDesktop platform contract