Skip to content

Latest commit

 

History

History
352 lines (293 loc) · 18.4 KB

spec.md

File metadata and controls

352 lines (293 loc) · 18.4 KB

Chat Scroll Ownership Architecture

Scope

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.

Implemented foundation

  • useChatScrollController is 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.

User need

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.

Goals

  1. Make the message viewport the only element that owns chat scrolling.
  2. Route every programmatic scroll through one typed controller with explicit reason and priority.
  3. Give an active user gesture durable ownership until the user explicitly returns to the bottom or requests a navigation action.
  4. Preserve current short-chat, long-chat, streaming, history, search, Spotlight, capture, interaction, pending-input, read-only, and session-switch behavior.
  5. Keep the bounded message window and logical message addressing for long-chat performance.
  6. Preserve all stored sessions, messages, cursors, settings, drafts, and pending inputs without migration.
  7. Reduce ChatPage responsibilities into focused components and composables with black-box tests.
  8. Add real Chromium coverage for layout-dependent scroll behavior.
  9. Make first chat paint and session switching fast without trading away loaded history, search counts, or data correctness.

Non-goals

  • 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.

Required architecture

Page geometry

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.

Single scroll owner

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.

Ownership and priority

Priority is explicit and stable:

  1. Active user gesture or durable reading mode.
  2. Explicit user navigation: search, Spotlight, return-to-bottom.
  3. History-prepend anchor preservation.
  4. Session restore before any user interruption.
  5. Generation auto-follow when enabled and still owned.
  6. 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.

Controller state

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.

Short and long conversations

  • 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.

Session restore

  • 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 restoring or following and 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.

First load and session switching

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.

Streaming and submit behavior

  • Submitting a new message explicitly transfers ownership to bottom-follow for that new turn.
  • Streaming follows the bottom only when autoScrollEnabled is 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.

History pagination

  • 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 scroll event.
  • 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 scrollend with an idle fallback.

Search, Spotlight, and editor containment

  • Search and Spotlight resolve a messageId through 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.

Functional compatibility matrix

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

Data and compatibility guarantees

  • 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.
  • autoScrollEnabled retains 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.

Performance and experience acceptance criteria

  1. During an active user gesture, no unauthorized programmatic scroll write occurs.
  2. In reading mode, the selected logical anchor drifts by at most 1 CSS pixel across Markdown, image, tool, composer, and streaming layout changes.
  3. Short conversations with 1–20 messages perform zero measurement-driven scroll writes.
  4. One user navigation action produces at most one viewport write plus one highlight-only DOM pass.
  5. One animation frame contains at most one committed viewport scroll write.
  6. Across frames, at most one scroll transaction remains active; no second owner can alternate writes with it.
  7. Long conversations keep mounted heavy message rows bounded by the active window and overscan.
  8. Continuous wheel/trackpad scrolling has no application-generated long task above 50ms and no repeated frame-by-frame full-list conversion.
  9. Streaming scroll work is coalesced to animation frames; token frequency does not equal scroll write frequency.
  10. Search, history, and Spotlight navigation complete without visible intermediate window swaps.
  11. Existing renderer unit suites, focused ownership tests, typecheck, lint, and i18n pass.
  12. Chromium integration scenarios show no flash, rollback, native clamp, or scrollbar theft.
  13. 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.
  14. Session switching performs no full heavy-DOM mount for every loaded message and produces no intermediate empty or mixed-session frame.

Maintainability acceptance criteria

  • ChatPage.vue becomes 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.

Linked specifications

  • docs/ARCHITECTURE.md Desktop platform contract