Skip to content

fix(chat): stabilize scroll ownership#1974

Merged
zerob13 merged 3 commits into
devfrom
fix/chat-scroll-ownership
Jul 15, 2026
Merged

fix(chat): stabilize scroll ownership#1974
zerob13 merged 3 commits into
devfrom
fix/chat-scroll-ownership

Conversation

@zhangmo8

@zhangmo8 zhangmo8 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • centralize every ChatPage viewport write behind one typed scroll controller, request arbiter, and one-write-per-frame queue
  • preserve durable user ownership across restore, streaming follow, history prepend, measurement anchoring, Cmd/Ctrl+F, and Spotlight navigation
  • commit target-session message views atomically and reuse a bounded recent-session cache for faster initial paint and switching
  • isolate header, viewport, and composer geometry; keep loading indicators out of message flow and use a chat-shaped skeleton for uncached switches
  • require a real loaded-history window, upward intent, top proximity, and scroll idle before pagination

UI Structure

Before

ChatPage shared flow
+-- top bar
+-- message viewport
|   +-- in-flow loading state
|   +-- message rows
|   +-- restore / follow / history / search writers
+-- plan and pending UI
+-- composer

After

ChatPage grid
+-- top bar
+-- viewport region
|   +-- message scroll owner
|   |   +-- zero-height history status
|   |   +-- bounded message window
|   +-- uncached-session skeleton overlay
+-- composer region

all scroll intents -> controller -> one active operation -> one write per frame

Behavior and Compatibility

  • user wheel, touch, scrollbar, or keyboard intent cancels active and queued programmatic work
  • cached session switches render immediately; uncached switches hide stale content until one atomic commit
  • short conversations do not trigger history loading or measurement-driven scroll writes
  • search and Spotlight retain explicit navigation priority without triggering pagination
  • recent session views are capped at 5 entries and approximately 24 MB
  • existing messages and persisted session data are not migrated or rewritten

Validation

  • focused renderer regression: 9 files, 141 tests passed
  • stabilized full renderer run: 167 files, 1252 tests passed
  • production build passed
  • pnpm run typecheck:web
  • pnpm run format
  • pnpm run i18n
  • pnpm run lint
  • git diff --check

Remaining Manual Validation

  • run the provider-backed Electron scroll ownership smoke scenario
  • verify physical macOS trackpad and mouse-wheel behavior in short and multi-page conversations
  • record cold-load, cached-switch, uncached-switch, and anchor-drift metrics on the reference machine

Summary by CodeRabbit

  • New Features
    • Improved chat scrolling with exclusive scrollbar ownership across session restore, streaming, search, history loading, and message navigation.
    • Added bounded message rendering to keep performance stable in long chats.
    • Preserved recent session views and message measurements across session switches and remounts.
    • Added a chat session loading skeleton while content prepares.
  • Bug Fixes
    • Reduced unwanted scroll jumps, incorrect history pagination triggers, and stale UI updates during session switching and message resizing.
  • Tests
    • Added Playwright smoke coverage and expanded renderer unit tests for scroll ownership and caching behavior.

Centralize viewport writes behind one request arbiter.
Preserve user ownership across restore, history, search, and
measurement flows.

Commit session views atomically, cache recent sessions, and show a
non-scrolling skeleton while uncached sessions prepare.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a48caf5b-154c-446b-8613-b6b4f6728ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 193758b and 558f8f5.

📒 Files selected for processing (1)
  • test/renderer/composables/chat/chatScrollArchitecture.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/renderer/composables/chat/chatScrollArchitecture.test.ts

📝 Walkthrough

Walkthrough

The PR centralizes chat viewport scrolling behind typed state, request arbitration, and session-aware controller logic. It updates message-window geometry, session restoration, logical-anchor pagination, search navigation, bounded caches, and related renderer, component, architecture, and Electron smoke tests.

Changes

Chat scroll ownership and layout

Layer / File(s) Summary
Architecture contracts and migration plan
docs/architecture/chat-scroll-ownership/*, docs/architecture/chat-scroll-windowing/spec.md, docs/issues/chat-history-search-scroll-coordinates/spec.md
Documents single-owner scrolling, bounded layout, logical coordinates, session epochs, migration phases, compatibility requirements, and validation criteria.
Scroll controller and layout primitives
src/renderer/src/composables/chat/*, src/renderer/src/composables/message/*, src/renderer/src/components/chat/*
Adds pure scroll transitions, request queueing and arbitration, controller-driven viewport writes, row-spacing calculations, measurement snapshots, and bounded measurement caching.
Session-safe store and page integration
src/renderer/src/stores/ui/*, src/renderer/src/pages/ChatPage.vue
Adds atomic committed-session views, recent-session caching, logical anchor restoration, controller-based pagination/search scrolling, measurement batching, and session-preparation gating.
Validation coverage
test/renderer/components/*, test/renderer/stores/*, test/renderer/composables/*, test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts
Adds coverage for scroll ownership, session switching, cache behavior, layout geometry, pagination, search navigation, direct-write architecture rules, and native viewport-write instrumentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatPage
  participant ChatScrollController
  participant MessageStore
  participant Viewport
  User->>ChatPage: gesture, search, submit, or session switch
  ChatPage->>ChatScrollController: submit typed scroll request
  ChatScrollController->>MessageStore: resolve session/message layout state
  ChatScrollController->>Viewport: commit one expected scroll write
  Viewport->>ChatScrollController: report scroll or resize
  ChatScrollController->>ChatPage: update ownership and completion state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: stabilizing chat scroll ownership.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/chat-scroll-ownership

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts (2)

71-76: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Check the instance before traversing up the prototype chain.

The current loop skips checking the element instance itself because it immediately reassigns the prototype reference before checking for the property descriptor. While DOM elements typically inherit scrollTop from Element.prototype, standard prototype traversal should check the object itself first so it doesn't bypass potential instance-level overrides.

💡 Proposed fix for prototype traversal
-    let prototype: object | null = element
+    let current: object | null = element
     let descriptor: PropertyDescriptor | undefined
-    while (prototype && !descriptor) {
-      prototype = Object.getPrototypeOf(prototype)
-      descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, 'scrollTop') : undefined
+    while (current && !descriptor) {
+      descriptor = Object.getOwnPropertyDescriptor(current, 'scrollTop')
+      if (!descriptor) {
+        current = Object.getPrototypeOf(current)
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts` around lines 71 - 76,
Update the prototype traversal loop around prototype and descriptor so it checks
the current element or prototype for scrollTop before advancing with
Object.getPrototypeOf. Preserve traversal through successive prototypes until a
descriptor is found or the chain ends, including support for instance-level
overrides.

84-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider covering programmatic scroll methods.

This instrumentation successfully intercepts direct assignments to scrollTop. However, if the application's scroll controller ever uses Element.prototype.scrollTo(), scrollBy(), or scroll(), those programmatic writes will bypass this proxy and remain unrecorded.

If full coverage is desired against future refactors, you might also want to intercept and record calls to these scroll methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts` around lines 84 - 91,
Extend the scroll instrumentation around the existing scrollTop proxy to also
intercept Element.prototype.scrollTo(), scrollBy(), and scroll(), recording each
programmatic scroll operation in __deepchatScrollWrites before delegating to the
native implementation. Preserve the current direct scrollTop assignment tracking
and original method behavior.
test/renderer/composables/chatScrollArchitecture.test.ts (1)

10-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Enforce exclusive scroll ownership across the renderer.

This only rejects writes in ChatPage.vue; a direct write added to MessageList.vue or another composable would pass. Scan renderer sources while excluding the controller’s single authorized assignment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/renderer/composables/chatScrollArchitecture.test.ts` around lines 10 -
28, Expand the scroll-write ownership test to scan all relevant renderer source
files, not only ChatPage.vue, for direct viewport scrolling APIs. Exclude
useChatScrollController.ts from the forbidden-write scan, while retaining its
assertion that exactly one viewport.scrollTop assignment exists there.
test/renderer/composables/chatScrollState.test.ts (1)

1-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the new chat composable suites into the mirrored chat/ test directory.

  • test/renderer/composables/chatScrollState.test.ts#L1-L10: move to test/renderer/composables/chat/chatScrollState.test.ts.
  • test/renderer/composables/useChatScrollController.test.ts#L1-L3: move to test/renderer/composables/chat/useChatScrollController.test.ts.

As per coding guidelines, renderer Vitest suites must mirror the source layout under test/renderer/. <coding_guidelines>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/renderer/composables/chatScrollState.test.ts` around lines 1 - 10, Move
test/renderer/composables/chatScrollState.test.ts to
test/renderer/composables/chat/chatScrollState.test.ts, preserving its
ChatScrollState test contents. Move
test/renderer/composables/useChatScrollController.test.ts to
test/renderer/composables/chat/useChatScrollController.test.ts, preserving its
useChatScrollController tests; no other changes are required.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderer/src/composables/chat/chatScrollRequestQueue.ts`:
- Around line 6-16: Update ChatScrollRequestQueue.enqueue and take so requests
from older sessionEpoch values cannot replace or clear a pending request from a
newer epoch. Compare epoch ordering explicitly, retain the newer pending request
when a stale request arrives, and validate before clearing pendingRequest so
stale take attempts leave current-session work intact.

In `@src/renderer/src/composables/chat/useChatScrollController.ts`:
- Around line 253-256: Update the near-bottom transition in the scroll
controller so return-to-bottom is dispatched only when nearBottom is accompanied
by active user intent, such as state.value.activeGesture or the established
explicit intent marker. Do not trigger return-to-bottom from layout- or
measurement-induced native scrolls, and preserve user ownership through those
events while retaining the existing user/native return classification.
- Around line 153-161: Reset writeCommittedThisFrame after every physical scroll
write in the immediate-commit path, including the corresponding write near
scheduleCommit, by scheduling a boundary reset on the next animation frame after
viewport.scrollTop is assigned. Preserve normal commit-frame behavior and add a
regression test that issues a second immediate request after verification
completes, confirming it is not delayed.

In `@src/renderer/src/pages/ChatPage.vue`:
- Around line 385-398: The restoreSessionMessages flow must only expose a
session after messageStore has committed the requested session. After
loadMessagesForSession and the requestId check, verify
messageStore.committedSessionId equals id; if not, retain the safe loading/error
state and return without setting committedMessageSessionId or enabling the
target composer. Keep the existing commit, performance-marking, and
summary-application path for successfully committed sessions.
- Around line 744-752: Update endListGesture and the related list-scroll event
handling so touch ownership remains active during native inertial scrolling:
defer finishListScrollingAfterIdle until scrollend, retain the idle timer as a
fallback, and refresh that timer for user-sourced momentum scroll events.
Preserve the existing cleanup for inactive gestures and reset gesture state when
scrolling truly ends.
- Around line 754-769: Update the upward-intent handlers, including
onListTouchMove and the corresponding wheel, scrollbar, and keyboard handlers,
to set pendingHistoryLoadAtIdle immediately when upward intent begins and
scrollTop is at or below TOP_HISTORY_THRESHOLD. Preserve existing gesture-state
and intent-reset behavior, and ensure this works even when no scroll event is
emitted.

In `@test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts`:
- Around line 118-119: Replace the fixed waitForTimeout after
searchInput.fill(firstToken) with a reliable completion signal for the chat
search, such as expect.poll() or an awaited DOM state change indicating search
processing and scrolling have finished. Then retain the scrollTop assertion,
ensuring it runs only after the search result is settled.

---

Nitpick comments:
In `@test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts`:
- Around line 71-76: Update the prototype traversal loop around prototype and
descriptor so it checks the current element or prototype for scrollTop before
advancing with Object.getPrototypeOf. Preserve traversal through successive
prototypes until a descriptor is found or the chain ends, including support for
instance-level overrides.
- Around line 84-91: Extend the scroll instrumentation around the existing
scrollTop proxy to also intercept Element.prototype.scrollTo(), scrollBy(), and
scroll(), recording each programmatic scroll operation in __deepchatScrollWrites
before delegating to the native implementation. Preserve the current direct
scrollTop assignment tracking and original method behavior.

In `@test/renderer/composables/chatScrollArchitecture.test.ts`:
- Around line 10-28: Expand the scroll-write ownership test to scan all relevant
renderer source files, not only ChatPage.vue, for direct viewport scrolling
APIs. Exclude useChatScrollController.ts from the forbidden-write scan, while
retaining its assertion that exactly one viewport.scrollTop assignment exists
there.

In `@test/renderer/composables/chatScrollState.test.ts`:
- Around line 1-10: Move test/renderer/composables/chatScrollState.test.ts to
test/renderer/composables/chat/chatScrollState.test.ts, preserving its
ChatScrollState test contents. Move
test/renderer/composables/useChatScrollController.test.ts to
test/renderer/composables/chat/useChatScrollController.test.ts, preserving its
useChatScrollController tests; no other changes are required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8642481-4758-4080-826c-ed2856faa315

📥 Commits

Reviewing files that changed from the base of the PR and between a461415 and 4714d71.

📒 Files selected for processing (27)
  • docs/architecture/chat-scroll-ownership/plan.md
  • docs/architecture/chat-scroll-ownership/spec.md
  • docs/architecture/chat-scroll-ownership/tasks.md
  • docs/architecture/chat-scroll-windowing/spec.md
  • docs/issues/chat-history-search-scroll-coordinates/spec.md
  • src/renderer/src/components/chat/ChatSessionSkeleton.vue
  • src/renderer/src/components/chat/MessageList.vue
  • src/renderer/src/components/chat/MessageListRow.vue
  • src/renderer/src/composables/chat/chatScrollOperationArbiter.ts
  • src/renderer/src/composables/chat/chatScrollRequestQueue.ts
  • src/renderer/src/composables/chat/chatScrollState.ts
  • src/renderer/src/composables/chat/chatSessionPerformance.ts
  • src/renderer/src/composables/chat/useChatScrollController.ts
  • src/renderer/src/composables/message/useMessageWindow.ts
  • src/renderer/src/pages/ChatPage.vue
  • src/renderer/src/stores/ui/message.ts
  • src/renderer/src/stores/ui/recentMessageViewCache.ts
  • test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts
  • test/renderer/components/ChatPage.test.ts
  • test/renderer/components/MessageList.test.ts
  • test/renderer/components/MessageListRow.test.ts
  • test/renderer/composables/chatScrollArchitecture.test.ts
  • test/renderer/composables/chatScrollState.test.ts
  • test/renderer/composables/useChatScrollController.test.ts
  • test/renderer/composables/useMessageWindow.test.ts
  • test/renderer/stores/messageStore.test.ts
  • test/renderer/stores/recentMessageViewCache.test.ts

Comment thread src/renderer/src/composables/chat/chatScrollRequestQueue.ts
Comment thread src/renderer/src/composables/chat/useChatScrollController.ts
Comment thread src/renderer/src/composables/chat/useChatScrollController.ts Outdated
Comment thread src/renderer/src/pages/ChatPage.vue
Comment thread src/renderer/src/pages/ChatPage.vue
Comment thread src/renderer/src/pages/ChatPage.vue
Comment thread test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts Outdated

@zhangmo8 zhangmo8 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found three additional production-path issues that should be addressed before merge. The combined #1973 + #1974 tree merges cleanly and passes typecheck plus the focused skill/message/scroll suites, so these findings are isolated to scroll eligibility, coalesced navigation state, and cache lifetime.

Comment thread src/renderer/src/pages/ChatPage.vue
Comment thread src/renderer/src/composables/chat/chatScrollState.ts Outdated
Comment thread src/renderer/src/pages/ChatPage.vue Outdated
Preserve user and session boundaries across queued, resize, touch,
navigation, and keyed remount flows.

Replace timing-only smoke assertions and widen the architecture guard.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/renderer/src/composables/message/recentMessageMeasurementCache.ts (1)

22-38: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a strict undefined check for oldestSessionId.

In JavaScript, !oldestSessionId is truthy for an empty string (""). While it's extremely unlikely for a session ID to be an empty string, using a strict undefined check is more robust and clearly signals that you are checking for iterator exhaustion rather than a generic falsy string.

💡 Proposed refactor
   set(sessionId: string, snapshot: MessageMeasurementSnapshot): void {
     this.entries.delete(sessionId)
     this.entries.set(sessionId, Object.freeze({ ...snapshot }))
     while (this.entries.size > this.maxEntries) {
       const oldestSessionId = this.entries.keys().next().value
-      if (!oldestSessionId) return
+      if (oldestSessionId === undefined) return
       this.entries.delete(oldestSessionId)
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/src/composables/message/recentMessageMeasurementCache.ts` around
lines 22 - 38, Update the oldestSessionId guard in
RecentMessageMeasurementCache.set to check specifically for undefined,
preserving deletion of valid session IDs including an empty string while still
returning when the iterator is exhausted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/renderer/composables/chat/chatScrollArchitecture.test.ts`:
- Around line 46-55: Normalize the paths added to files in the visit function by
converting Windows backslashes from relative(process.cwd(), path) to forward
slashes. Keep the existing extension filtering and recursive traversal unchanged
so comparisons with CONTROLLER_PATH and allowedDirectScrollWrites use consistent
POSIX separators.

---

Nitpick comments:
In `@src/renderer/src/composables/message/recentMessageMeasurementCache.ts`:
- Around line 22-38: Update the oldestSessionId guard in
RecentMessageMeasurementCache.set to check specifically for undefined,
preserving deletion of valid session IDs including an empty string while still
returning when the iterator is exhausted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ca83294-0f14-4030-84c5-662eb116af71

📥 Commits

Reviewing files that changed from the base of the PR and between 4714d71 and 193758b.

📒 Files selected for processing (14)
  • docs/architecture/chat-scroll-ownership/spec.md
  • docs/architecture/chat-scroll-ownership/tasks.md
  • src/renderer/src/composables/chat/chatScrollRequestQueue.ts
  • src/renderer/src/composables/chat/chatScrollState.ts
  • src/renderer/src/composables/chat/useChatScrollController.ts
  • src/renderer/src/composables/message/recentMessageMeasurementCache.ts
  • src/renderer/src/pages/ChatPage.vue
  • test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts
  • test/renderer/components/ChatPage.test.ts
  • test/renderer/components/ChatTabView.test.ts
  • test/renderer/composables/chat/chatScrollArchitecture.test.ts
  • test/renderer/composables/chat/chatScrollState.test.ts
  • test/renderer/composables/chat/useChatScrollController.test.ts
  • test/renderer/composables/message/recentMessageMeasurementCache.test.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/renderer/src/composables/chat/chatScrollRequestQueue.ts
  • src/renderer/src/composables/chat/chatScrollState.ts
  • test/e2e/specs/31-chat-scroll-ownership.smoke.spec.ts
  • docs/architecture/chat-scroll-ownership/spec.md
  • src/renderer/src/composables/chat/useChatScrollController.ts
  • test/renderer/components/ChatPage.test.ts
  • src/renderer/src/pages/ChatPage.vue

Comment thread test/renderer/composables/chat/chatScrollArchitecture.test.ts
@zerob13 zerob13 merged commit c822e7c into dev Jul 15, 2026
4 checks passed
@zhangmo8 zhangmo8 deleted the fix/chat-scroll-ownership branch July 16, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants