Skip to content

Chat: fix lost stick-to-bottom on card-heavy answers (/getting-started)#1504

Closed
michaelassraf wants to merge 2 commits into
mainfrom
claude/global-ask-ai-sticky-fix-0ffb15
Closed

Chat: fix lost stick-to-bottom on card-heavy answers (/getting-started)#1504
michaelassraf wants to merge 2 commits into
mainfrom
claude/global-ask-ai-sticky-fix-0ffb15

Conversation

@michaelassraf

@michaelassraf michaelassraf commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Running a card-heavy slash command in the Mingo panel (reported on /getting-started) loses stick-to-bottom: the thread stops following and the rest of the answer lands below the fold.

Measured live: the scroller ended 4081px, then 6354px, then 18619px above the bottom across three runs.

Root cause

ChatMessageList delegates follow-the-bottom entirely to use-stick-to-bottom, which follows only while its internal isAtBottom flag is true. This list's layout loses that flag silently in two ways:

  1. The scroller's own box changes. The library's ResizeObserver watches contentRef, which lives inside the scroller. The streaming loader unmounting and the host's source-chip row mounting are siblings below the scroller, so they change its clientHeight, moving the bottom, with no event the library can observe.
  2. Resize-induced scroll events misread as a gesture. Fetch-mode entity cards mount as skeletons and settle to a different height; a shrink clamps scrollTop down, and the library's scroll handler reads that as a user scroll-up and escapes the lock permanently. Its resizeDifference guard is a setTimeout(..., 1) race, hence the intermittency.

/getting-started hits both hard: presentation: 'list' produces a dozen [card://onboarding_guide:...] markers, i.e. fetch-mode cards with 1200x630 covers that keep resolving for seconds after the stream ends. Verified from the raw SSE frames that this turn carries no scrollAnchor, so the deliberate top-anchor path is not involved.

Fix

The list now owns the intent instead of trusting the library's flag:

  • Sending a message (or opening a dialog) arms followBottomRef.
  • While armed, one ResizeObserver on both the content and the scroller re-asserts scrollToBottom. That call also re-sets the library's isAtBottom, so a lost lock self-heals on the next resize rather than staying broken for the rest of the turn.
  • Only a real gesture disarms: wheel-up, upward touchmove, ArrowUp/PageUp/Home, or a scrollTop decrease while the pointer is held (scrollbar drag). Resize-derived scrolls never count, which is exactly the distinction the library gets wrong.
  • A scrollAnchor: 'top' turn disarms explicitly, since it deliberately parks at the message top.

Deletes the old per-<img> load-handler hack: same defect class, and the ResizeObserver covers every growth source, not just images present at send time.

Testing

  • 10 tests pass in chat-message-list.test.tsx: the 4 existing plus 6 new regressions covering late async growth, gesture release, downward-wheel non-release, autoScroll={false}, and the top-anchor carve-out.
  • tsc --noEmit clean for the changed file.
  • Not yet browser-verified. This is scroll UX, so unit tests are not sufficient. The worktree's npm install first died on a transient ETARGET for @radix-ui/[email protected]; the retry is running and a live /getting-started check plus a device pass are still outstanding before merge.

Follow-up

Hub pins an exact lib version, so shipping needs a lib release and a hub pin bump.

Generated with Claude Code


Update: industry-standard audit

Re-checked this against what shipping AI chats do in 2026.

Validated. The container-resize blind spot is a known, still-open upstream bug: use-stick-to-bottom#40, "scroll position drifts when scroll container height changes (flex sibling resize)""The library's ResizeObserver only monitors the content element, not the scroll container itself... Proposed fix: implement a second ResizeObserver attached to the scroll container." We resolve to 1.1.6 (latest), so it is unfixed upstream, and this PR implements precisely the remedy the issue proposes. Vercel AI Elements' <Conversation> still uses the same library, so the reference implementation shares the defect.

Not claimed as standard. The "intent lock armed on send, released only on a real gesture" is not a shipped primitive anywhere reviewed (assistant-ui, LibreChat, Open WebUI, AI Elements). It is a deliberate local choice: resize-derived scrolls are not gestures, which is exactly the distinction the library's heuristic gets wrong.

Known divergence, accepted. The market leaders (ChatGPT, Claude.ai, Gemini) anchor the sent message to the TOP and do not chase the answer; assistant-ui codifies this as turnAnchor="top" (which flips autoScroll to false). This repo already owns that primitive as scrollAnchor: 'top' in display-action-response.ts, applied to display-action answers only. Extending it to slash-command list answers was considered and explicitly declined for now: bottom-follow is the chosen behavior here. Revisit if reading long listings from the end proves annoying.

Added in this PR: the jump-to-bottom button, which AI Elements ships as ConversationScrollButton and which is the practical escape hatch for WCAG 2.2.2 / F16. Its visibility is derived from real geometry rather than the library's isAtBottom, because a stale true on that flag would hide the button in exactly the situation it exists for. Clicking it re-arms the follow lock.

Deferred (not in this PR): prefers-reduced-motion gating on the spring re-assert (this repo already honors the query in use-scripted-stream.ts:25), and an aria-live region for the answer body (today only the streaming loader has one).

Summary by CodeRabbit

  • New Features

    • Improved automatic scrolling to keep chats anchored to the latest message as content loads or changes size.
    • Added a “Scroll to latest message” button when viewing older messages.
    • Preserved intentional scrolling positions, including top-anchored messages.
  • Bug Fixes

    • Prevented unexpected jumps to the bottom after scrolling upward.
    • Improved handling of late-loading content and repeated layout changes.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

ChatMessageList now owns bottom-follow intent, measures scroll geometry, responds to resize and deliberate user scrolling, clears follow mode for top-anchored messages, and provides a jump-to-latest button. Tests add deterministic resize, geometry, ref, and scroll behavior coverage.

Changes

Chat bottom-follow behavior

Layer / File(s) Summary
Follow intent state and arming
openframe-frontend-core/src/components/chat/chat-message-list.tsx
Adds bottom-distance tracking and list-owned follow intent, arms it for forced scrolling, and clears it during top-anchored assistant scrolling.
Geometry tracking and jump control
openframe-frontend-core/src/components/chat/chat-message-list.tsx
Reasserts bottom scrolling after content or scroller resizes, releases intent for deliberate upward interactions, and adds the conditional “Scroll to latest message” button.
Bottom-follow behavior validation
openframe-frontend-core/src/components/chat/__tests__/chat-message-list.test.tsx
Adds forwarded-ref mocks and deterministic resize/geometry helpers, then tests resize following, gesture handling, auto-scroll gating, jump-button behavior, and top anchoring.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatMessageList
  participant ResizeObserver
  participant ScrollContainer
  User->>ChatMessageList: send turn or click jump-to-latest
  ChatMessageList->>ScrollContainer: scrollToBottom
  ResizeObserver->>ChatMessageList: report content or scroller resize
  ChatMessageList->>ScrollContainer: reassert scrollToBottom
  User->>ChatMessageList: upward wheel or scroll gesture
  ChatMessageList->>ChatMessageList: clear followBottomRef
Loading

Possibly related PRs

Suggested reviewers: oleksandr-blip

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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: fixing lost stick-to-bottom behavior for card-heavy chat answers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 claude/global-ask-ai-sticky-fix-0ffb15

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

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@openframe-frontend-core/src/components/chat/chat-message-list.tsx`:
- Around line 278-367: The follow-bottom useEffect must rerun when loading
finishes. Add isLoading to the dependency array of the effect containing
reassert, ResizeObserver, and scroll listeners, preserving the existing
early-return behavior and setup once the real list refs are available.
🪄 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 Plus

Run ID: acc643b0-fcf7-43bf-974c-672dd5dde9dc

📥 Commits

Reviewing files that changed from the base of the PR and between 27a4219 and 21045ee.

📒 Files selected for processing (2)
  • openframe-frontend-core/src/components/chat/__tests__/chat-message-list.test.tsx
  • openframe-frontend-core/src/components/chat/chat-message-list.tsx

Comment on lines +278 to +367
useEffect(() => {
if (!autoScroll) return
const scroller = scrollRef.current
const content = contentRef.current
if (!scroller || !content) return

// Re-assert on ANY geometry change. Deliberately WITHOUT
// `ignoreEscapes`: the library must stay free to hand control back
// on a real gesture mid-animation. Re-asserting is precisely what
// re-sets its `isAtBottom`, so a silently-lost lock recovers on the
// next resize instead of staying broken for the rest of the turn.
// Cheap to call repeatedly — the library returns the in-flight
// promise when an animation with the same behaviour is running.
const measure = () => {
const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight
setAtBottom(distance <= BOTTOM_THRESHOLD_PX)
}

const reassert = () => {
if (followBottomRef.current) void scrollToBottom({ animation: 'smooth' })
// Measured on every geometry change, follow armed or not: a
// sibling growing below the scroller moves the bottom without
// any scroll event, and the button has to notice.
measure()
}

const ro = new ResizeObserver(reassert)
ro.observe(content) // tokens, entity cards, images
ro.observe(scroller) // streaming loader / source chips below it

// --- user-intent disarm ---------------------------------------
// Only a deliberate move AWAY from the bottom releases the lock.
// Resize-derived scroll events are NOT gestures, which is exactly
// the distinction the library's heuristic gets wrong.
const disarm = () => {
followBottomRef.current = false
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) disarm()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home') disarm()
}
let touchY: number | null = null
const onTouchStart = (e: TouchEvent) => {
touchY = e.touches[0]?.clientY ?? null
}
const onTouchMove = (e: TouchEvent) => {
const y = e.touches[0]?.clientY
if (y == null || touchY == null) return
if (y > touchY) disarm() // finger travels DOWN → content moves up
touchY = y
}
// Scrollbar drag emits neither wheel nor touch — a scrollTop
// DECREASE while the pointer is held down is the same intent.
let pointerDown = false
let lastScrollTop = scroller.scrollTop
const onPointerDown = () => {
pointerDown = true
}
const onPointerUp = () => {
pointerDown = false
}
const onScroll = () => {
const st = scroller.scrollTop
if (pointerDown && st < lastScrollTop) disarm()
lastScrollTop = st
measure()
}
measure()

scroller.addEventListener('wheel', onWheel, { passive: true })
scroller.addEventListener('keydown', onKeyDown)
scroller.addEventListener('touchstart', onTouchStart, { passive: true })
scroller.addEventListener('touchmove', onTouchMove, { passive: true })
scroller.addEventListener('pointerdown', onPointerDown, { passive: true })
scroller.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('pointerup', onPointerUp, { passive: true })

return () => {
ro.disconnect()
scroller.removeEventListener('wheel', onWheel)
scroller.removeEventListener('keydown', onKeyDown)
scroller.removeEventListener('touchstart', onTouchStart)
scroller.removeEventListener('touchmove', onTouchMove)
scroller.removeEventListener('pointerdown', onPointerDown)
scroller.removeEventListener('scroll', onScroll)
window.removeEventListener('pointerup', onPointerUp)
}
}, [autoScroll, scrollRef, contentRef, scrollToBottom])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=tsx -C3 '<ChatMessageList\b' | rg -nC3 'isLoading'
rg -nP -C3 'isLoading' openframe-frontend-core/src/components/chat/chat-message-list.tsx

Repository: flamingo-stack/openframe-oss-lib

Length of output: 667


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="openframe-frontend-core/src/components/chat/chat-message-list.tsx"

echo "== file size =="
wc -l "$file"

echo
echo "== hook and loading branch context =="
sed -n '1,220p' "$file" | cat -n | sed -n '1,220p'
echo
sed -n '220,420p' "$file" | cat -n | sed -n '220,420p'
echo
sed -n '540,640p' "$file" | cat -n | sed -n '540,640p'

Repository: flamingo-stack/openframe-oss-lib

Length of output: 11762


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="openframe-frontend-core/src/components/chat/chat-message-list.tsx"

echo "== lines 250-390 =="
sed -n '250,390p' "$file" | cat -n

echo
echo "== search for effects depending on isLoading / loading transition =="
rg -n "isLoading|autoScroll|scrollToBottom|followBottomRef|atBottom|ResizeObserver|useEffect\(" "$file"

Repository: flamingo-stack/openframe-oss-lib

Length of output: 10628


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="openframe-frontend-core/src/components/chat/chat-message-list.tsx"

sed -n '460,620p' "$file" | cat -n

Repository: flamingo-stack/openframe-oss-lib

Length of output: 8629


Add isLoading to the follow-effect deps

On the loading render, this effect returns before scrollRef.current/contentRef.current exist, and it won’t run again when the skeleton swaps to the real list because isLoading is missing from the dependency array. That leaves the ResizeObserver and scroll listeners unmounted on the initial load path.

🤖 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 `@openframe-frontend-core/src/components/chat/chat-message-list.tsx` around
lines 278 - 367, The follow-bottom useEffect must rerun when loading finishes.
Add isLoading to the dependency array of the effect containing reassert,
ResizeObserver, and scroll listeners, preserving the existing early-return
behavior and setup once the real list refs are available.

@michaelassraf

Copy link
Copy Markdown
Contributor Author

Superseded — absorbed into #1506 rather than merged separately.

Both commits were cherry-picked clean onto claude/markdown-chat-unification-ccc032:

  • 064ce4d7 Chat: fix lost stick-to-bottom on card-heavy answers
  • 87483029 Chat: add jump-to-bottom button (WCAG 2.2.2 escape hatch)

No conflict with that branch's chat-message-list.tsx changes — git merge-tree reported zero conflicted paths, and the two are complementary: this PR decides where the scroller parks, the other decides whether an empty pending-turn row renders at all.

Three fixes were made on top while absorbing it:

  1. pointerdown moved from scroller to window (8e6e417a). Since FE core: OverlayScrollbars — macOS-like overlay scrollbars on all platforms #1487 landed OverlayScrollArea, OverlayScrollbars appends its handles to the host, not the viewport — so a pointerdown on the overlay thumb never reached scroller, leaving pointerDown false and yanking the user back down mid-drag. Verified against the installed library that it does not stopPropagation on its pointer path; listeners now use capture phase, except blur, which is deliberately non-capture (a capture-phase blur on window is a document-wide focus delegator and fired on every descendant blur).
  2. The follow lock now re-arms when the user scrolls back to the bottom by hand, gated on !pointerDown so it cannot fight the disarm mid-drag, with a one-shot re-arm in endPointer because releasing at the live end emits no further scroll event.
  3. pointerup/pointercancel/blur share one handler, so a drag released outside the window no longer leaves pointerDown stuck true (which would misread a card-settle scroll clamp as a gesture).

Also worth recording: the reported symptom on /blogs is this bug, not a separate one. /blogs is a chat slash command, not a route — it returns ~30 fetch-mode entity cards that settle after the stream ends, making it a heavier reproducer than /getting-started, and it emits no scrollAnchor, so the top-anchor carve-out is not involved.

Still needs the real-device check this PR's description flagged, once the lib release lands.

@michaelassraf
michaelassraf deleted the claude/global-ask-ai-sticky-fix-0ffb15 branch July 20, 2026 21:10
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.

1 participant