fix(app): own session timeline scrolling after submit#521
Conversation
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
📝 WalkthroughWalkthroughThis PR implements a comprehensive session timeline scroll controller addressing recurring jump-to-top regressions. It introduces a stateful controller distinguishing between following latest, reading history, and targeting messages, with safe-position sampling and restoration across submits, gestures, and layout resets. ChangesSession Timeline Scroll Ownership System
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive session timeline scroll controller to manage different scroll modes, including following the latest messages, reading history, and targeting specific messages. It adds logic for scroll intent classification, metric collection, and anchor-based scroll restoration to improve the user experience during layout shifts and content updates. Feedback focuses on preventing automated scroll snapping during manual scrollbar interaction, optimizing the performance of visible message detection to avoid layout thrashing, and refining diagnostic reason strings for better observability.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/app/src/pages/session/use-session-hash-scroll.ts (1)
125-132:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAvoid duplicate navigation intent emission for hash-resolved messages.
At Line 127,
applyHash()emitsonMessageNavigation, then Line 131 callsscrollToMessage(), which emits the same callback again at Line 94. That can produce duplicatetarget_messageintents.Suggested patch
const messageId = messageIdFromHash(hash) if (messageId) { - input.onMessageNavigation?.(messageId) input.autoScroll.pause() const msg = messageById().get(messageId) if (msg) { scrollToMessage(msg, behavior) return } + input.onMessageNavigation?.(messageId) return }🤖 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 `@packages/app/src/pages/session/use-session-hash-scroll.ts` around lines 125 - 132, The code in applyHash emits the navigation intent twice: it calls input.onMessageNavigation?.(messageId) before calling scrollToMessage, but scrollToMessage already emits the same callback; remove the redundant pre-emission. Concretely, in the block that resolves messageId via messageIdFromHash and pauses auto-scroll (the code that also calls messageById().get(messageId)), delete the direct call to input.onMessageNavigation?.(messageId) so only scrollToMessage(msg, behavior) triggers the navigation intent (or alternatively remove the emission inside scrollToMessage if you prefer that single source of truth).
🧹 Nitpick comments (3)
packages/app/e2e/session/session-renderer-diagnostics.spec.ts (1)
167-184: ⚡ Quick winAvoid binding this E2E to
ScrollViewCSS internals.
timelineThumbBox()reaches into.scroll-viewand.scroll-view__thumb, so a harmless class rename inpackages/uiwill break this spec. Please expose adata-component/data-actionhook for the scrollbar thumb and query that instead.As per coding guidelines, use
data-component,data-action, or semantic roles for selectors instead of CSS class names or IDs.🤖 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 `@packages/app/e2e/session/session-renderer-diagnostics.spec.ts` around lines 167 - 184, The test helper timelineThumbBox currently depends on internal CSS classes (".scroll-view" and ".scroll-view__thumb"); update the UI ScrollView component to add a stable data attribute (e.g. data-component="scroll-view" on the scroll container and data-action="scroll-thumb" on the thumb) and then change timelineThumbBox to query for those data attributes (use the existing scrollViewportSelector param for finding the viewport/root and replace ".scroll-view__thumb" with the new data-action selector) so the spec relies on stable data hooks instead of implementation CSS classes.packages/app/src/pages/session/use-session-timeline-interaction.ts (2)
77-83: 💤 Low valueConsider also detaching the controller on owner cleanup.
onCleanupcancels a pending recovery frame but never invokesscrollController.detach({ ... }). The session-change effect at lines 62-75 properly detaches the previous controller, but on Solid owner disposal (component unmount, route change) the controller silently goes out of scope without emitting theowner_detacheddiagnostic that the controller is otherwise designed to produce. If the diagnostic timeline is used to verify lifecycle, missing detach events on unmount will look like the previous owner is still active.♻️ Proposed fix
onCleanup(cancelRecoveryFrame) + onCleanup(() => { + const owner = scrollController.state() + scrollController.detach({ + sessionOwner: owner.sessionOwner, + viewportOwner: owner.viewportOwner, + }) + })🤖 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 `@packages/app/src/pages/session/use-session-timeline-interaction.ts` around lines 77 - 83, onCleanup currently only cancels the pending recoveryFrame but does not detach the scroll controller, so the controller never emits its owner_detached diagnostic on component unmount; update the cleanup handler (the function referenced by onCleanup/cancelRecoveryFrame) to also check for the scrollController instance and call its detach method (e.g., scrollController.detach(...)) with the appropriate owner_detached reason/metadata used elsewhere so the controller emits the same diagnostic on owner disposal as it does during session-change handling.
38-75: ⚡ Quick winUse the factory for the initial controller too.
The inline controller construction at lines 39-48 duplicates the body of
createScrollControllerat lines 51-60 verbatim. Any future change (e.g., new option, different diagnostic transform) has to be made in two places and is easy to miss. Reusing the factory for the initial seed eliminates that drift.♻️ Proposed fix
- let scrollController = createSessionTimelineScrollController({ - sessionOwner: input.sessionKey(), - viewportOwner: `timeline:${input.sessionKey()}`, - routeSessionID: input.routeSessionID(), - visibleSessionID: input.sessionID(), - timelineSessionID: input.sessionID(), - emitDiagnostic: (event) => { - void emitRendererDiagnostic(event).catch(() => {}) - }, - }) - - const createScrollController = () => - createSessionTimelineScrollController({ - sessionOwner: input.sessionKey(), - viewportOwner: `timeline:${input.sessionKey()}`, - routeSessionID: input.routeSessionID(), - visibleSessionID: input.sessionID(), - timelineSessionID: input.sessionID(), - emitDiagnostic: (event) => { - void emitRendererDiagnostic(event).catch(() => {}) - }, - }) + const createScrollController = () => + createSessionTimelineScrollController({ + sessionOwner: input.sessionKey(), + viewportOwner: `timeline:${input.sessionKey()}`, + routeSessionID: input.routeSessionID(), + visibleSessionID: input.sessionID(), + timelineSessionID: input.sessionID(), + emitDiagnostic: (event) => { + void emitRendererDiagnostic(event).catch(() => {}) + }, + }) + + let scrollController = createScrollController()🤖 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 `@packages/app/src/pages/session/use-session-timeline-interaction.ts` around lines 38 - 75, The initial scrollController is constructed inline duplicating createScrollController; instead call createScrollController to seed scrollController so there's a single factory point. Replace the inline createSessionTimelineScrollController usage that assigns scrollController (the block creating recoveryFrame and scrollController) with: let scrollController = createScrollController() (keeping recoveryFrame), ensuring the createScrollController function (which wraps createSessionTimelineScrollController and the emitRendererDiagnostic wrapper) is the sole place constructing the controller referenced by createEffect (on input.sessionKey()/input.sessionID()).
🤖 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 `@packages/app/src/pages/session/message-timeline.tsx`:
- Around line 302-303: The timeline scroll-intent path doesn't mark legacy
user-scroll state, so keyboard arrow and other explicit intents never set
hasScrollGesture and leave createAutoScroll.userScrolled() false; in the
onTimelineScrollIntent handler (and any place that forwards
TimelineScrollIntent), detect explicit user intents (e.g. keyboard
ArrowUp/ArrowDown and scrollbar-drag/drag-like intents) and call the same legacy
bookkeeping used by pointer/wheel handlers: invoke markScrollGesture() (or
directly call
createAutoScroll.userScrolled()/onUserScroll()/onAutoScrollHandleScroll() as the
pointer handlers do) so hasScrollGesture becomes true and downstream
dock-resize/auto-scroll logic respects intent.
In `@packages/app/src/pages/session/session-timeline-scroll-controller.ts`:
- Around line 556-564: In the branch inside session-timeline-scroll-controller
where state.mode === "following_latest" you should not set reason:
"explicit_bottom_navigation" for the restore_latest recovery because that label
is reserved for user-driven bottom navigations; instead, set the reason to a
more accurate value — use "submit_follow_latest" when submitOriginMode is
present/true (preserving existing submit-origin semantics) or use a new
dedicated reason like "follow_latest_preserved" (or
"content_resize_preserve_latest") otherwise; update the object returned by
result(...) (the recovery.type: "restore_latest" payload and its reason field)
and ensure the new reason is added to the TimelineScrollReason union so
diagnostics remain self-describing.
---
Outside diff comments:
In `@packages/app/src/pages/session/use-session-hash-scroll.ts`:
- Around line 125-132: The code in applyHash emits the navigation intent twice:
it calls input.onMessageNavigation?.(messageId) before calling scrollToMessage,
but scrollToMessage already emits the same callback; remove the redundant
pre-emission. Concretely, in the block that resolves messageId via
messageIdFromHash and pauses auto-scroll (the code that also calls
messageById().get(messageId)), delete the direct call to
input.onMessageNavigation?.(messageId) so only scrollToMessage(msg, behavior)
triggers the navigation intent (or alternatively remove the emission inside
scrollToMessage if you prefer that single source of truth).
---
Nitpick comments:
In `@packages/app/e2e/session/session-renderer-diagnostics.spec.ts`:
- Around line 167-184: The test helper timelineThumbBox currently depends on
internal CSS classes (".scroll-view" and ".scroll-view__thumb"); update the UI
ScrollView component to add a stable data attribute (e.g.
data-component="scroll-view" on the scroll container and
data-action="scroll-thumb" on the thumb) and then change timelineThumbBox to
query for those data attributes (use the existing scrollViewportSelector param
for finding the viewport/root and replace ".scroll-view__thumb" with the new
data-action selector) so the spec relies on stable data hooks instead of
implementation CSS classes.
In `@packages/app/src/pages/session/use-session-timeline-interaction.ts`:
- Around line 77-83: onCleanup currently only cancels the pending recoveryFrame
but does not detach the scroll controller, so the controller never emits its
owner_detached diagnostic on component unmount; update the cleanup handler (the
function referenced by onCleanup/cancelRecoveryFrame) to also check for the
scrollController instance and call its detach method (e.g.,
scrollController.detach(...)) with the appropriate owner_detached
reason/metadata used elsewhere so the controller emits the same diagnostic on
owner disposal as it does during session-change handling.
- Around line 38-75: The initial scrollController is constructed inline
duplicating createScrollController; instead call createScrollController to seed
scrollController so there's a single factory point. Replace the inline
createSessionTimelineScrollController usage that assigns scrollController (the
block creating recoveryFrame and scrollController) with: let scrollController =
createScrollController() (keeping recoveryFrame), ensuring the
createScrollController function (which wraps
createSessionTimelineScrollController and the emitRendererDiagnostic wrapper) is
the sole place constructing the controller referenced by createEffect (on
input.sessionKey()/input.sessionID()).
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 37480b01-f87e-4728-8e2b-982e65d6a40d
📒 Files selected for processing (19)
packages/app/e2e/session/session-renderer-diagnostics.spec.tspackages/app/e2e/session/session-scroll-position.spec.tspackages/app/src/pages/session.tsxpackages/app/src/pages/session/composer/session-composer-region.tsxpackages/app/src/pages/session/message-timeline.tsxpackages/app/src/pages/session/session-main-view.tsxpackages/app/src/pages/session/session-timeline-scroll-anchors.test.tspackages/app/src/pages/session/session-timeline-scroll-anchors.tspackages/app/src/pages/session/session-timeline-scroll-controller.test.tspackages/app/src/pages/session/session-timeline-scroll-controller.tspackages/app/src/pages/session/use-session-hash-scroll.test.tspackages/app/src/pages/session/use-session-hash-scroll.tspackages/app/src/pages/session/use-session-scroll-dock.test.tspackages/app/src/pages/session/use-session-scroll-dock.tspackages/app/src/pages/session/use-session-timeline-interaction.tspackages/desktop-electron/src/main/renderer-diagnostics.test.tspackages/desktop-electron/src/main/renderer-diagnostics.tspackages/ui/src/components/scroll-view.test.tspackages/ui/src/components/scroll-view.tsx
Summary
Adds a session timeline scroll controller so the main conversation timeline no longer treats raw scroll position as user intent. The controller owns submit/latest recovery, explicit navigation intents, message-anchor safe positions, dock/content observations, owner cleanup, and typed diagnostics.
Why
Closes #516.
The recurring jump-to-top bug came from split scroll ownership:
createAutoScroll, scroll dock, history window, hash scroll, timeline gesture handling, and dock resize paths could all interpret the same movement. A submit-time layout reset could therefore be recorded asuser_scrolled=trueand cancel latest recovery.Related Issue
Closes #516
Human Review Status
Pending. A human should make the final merge decision after reviewing the final diff and verification evidence.
Review Focus
SessionTimelineScrollControllerstate transitions and recovery reasons.message-timeline.tsxscroll path: rejected controller observations should not continue into old auto-scroll/user-scroll handling.use-session-timeline-interaction.tsrecovery scheduling and owner checks.ScrollViewintent reporting before keyboard/thumb mechanical scrolling.session.timeline.scroll_controller.Risk Notes
Visible session timeline behavior changed. Submit now explicitly returns the main timeline to latest, including when submitting from reading/hash mode. This matches the accepted #516 design, but it is the main product behavior to review.
Electron manual verification was not run because this worktree's
packages/app/AGENTS.mdsays never to restart the app/server process. Targeted web E2E and typecheck were run instead.How To Verify
Screenshots or Recordings
Not included. This is visible behavior, but the changed surface is scroll recovery and diagnostics rather than static UI. The targeted Playwright E2E covers the reproduced user path.
Checklist
dev, and my PR title and commit messages use Conventional Commits in English