fix(react): follow the live edge after an anchored reply fills the viewport (#11106)

* fix(react): follow the live edge after an anchored reply fills the viewport

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu

* fix(react): keep the scroll button quiet while follow-output is engaged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu

* fix(v4): wire base commands example menu items with onClick

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu

* style: format

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shadcn
2026-07-08 12:07:23 +04:00
committed by GitHub
parent 68e1f171aa
commit f6a2647854
8 changed files with 170 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@shadcn/react": patch
---
Fix MessageScroller autoScroll around anchored turns: the anchor hold now hands off to follow-output once the streamed reply fills the viewport, content growth outrunning the frame-coalesced resize handler no longer releases follow-output, and the scroll button no longer flickers while follow-output closes the gap a streamed chunk just opened.

View File

@@ -273,6 +273,11 @@ Scrolling away from the live edge releases the view, whether by wheel, touch,
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
releases it too. New chunks can then arrive without moving the reader.
`autoScroll` composes with turn anchoring. When a new turn anchors near the
top, the view stays put while the reply streams into the room below it. Once
the reply fills the viewport, the reader is back at the live edge and
follow-output takes over from the anchor.
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>{/* streamed turns */}</MessageScroller>

View File

@@ -273,6 +273,11 @@ Scrolling away from the live edge releases the view, whether by wheel, touch,
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
releases it too. New chunks can then arrive without moving the reader.
`autoScroll` composes with turn anchoring. When a new turn anchors near the
top, the view stays put while the reply streams into the room below it. Once
the reply fills the viewport, the reader is back at the live edge and
follow-output takes over from the anchor.
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>{/* streamed turns */}</MessageScroller>

View File

@@ -257,10 +257,10 @@ Which edges the viewport can scroll toward, for sibling UI that needs the values
in JavaScript. Prefer the `data-scrollable` attribute for styling the scroller
itself.
| Value | Type | Description |
| ------- | --------- | ------------------------------------------------------------------------------------------------------ |
| `start` | `boolean` | Whether the viewport can scroll toward the start. Content is hidden above (`!start` means at the top). |
| `end` | `boolean` | Whether the viewport can scroll toward the end. Content is hidden below (`!end` means at the bottom). |
| Value | Type | Description |
| ------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start` | `boolean` | Whether the viewport can scroll toward the start. Content is hidden above (`!start` means at the top). |
| `end` | `boolean` | Whether the viewport can scroll toward the end. Content is hidden below (`!end` means at the bottom). Stays `false` while follow-output is keeping the reader at the live edge. |
### useMessageScrollerVisibility

View File

@@ -143,7 +143,7 @@ function CommandMenu() {
{userMessages.map((message) => (
<DropdownMenuItem
key={message.id}
onSelect={() =>
onClick={() =>
scrollToMessage(message.id, {
align: "start",
behavior: "smooth",

View File

@@ -485,14 +485,76 @@ describe("MessageScroller", () => {
expect(rendered.viewport().scrollTop).toBe(176)
expect(rendered.message("message-4").getBoundingClientRect().top).toBe(64)
// Streamed growth below the anchor must not yank the reader to the live
// edge; the anchored turn holds at the reading line.
// The anchored turn was placed with no tail spacer (the content already
// fills the viewport), so growth must keep holding it at the reading line
// instead of yanking the reader to the live edge.
rendered.message("message-4").dataset.testHeight = "160"
await triggerResize(rendered.content())
expect(rendered.message("message-4").getBoundingClientRect().top).toBe(64)
})
it("hands off from the anchor hold to following once the streamed reply fills the viewport", async () => {
const rendered = await renderTestScroller({
autoScroll: true,
defaultScrollPosition: "end",
messages: [
{ id: "message-1", height: 80 },
{ id: "message-2", height: 80 },
{ id: "message-3", height: 80 },
],
})
expect(rendered.viewport().scrollTop).toBe(140)
// The submitted turn anchors at the reading line with tail spacer room
// below for the reply to stream into.
await rendered.rerender(
[
{ id: "message-1", height: 80 },
{ id: "message-2", height: 80 },
{ id: "message-3", height: 80 },
{ id: "message-4", height: 20, scrollAnchor: true },
],
{ autoScroll: true }
)
expect(rendered.viewport().scrollTop).toBe(176)
expect(rendered.message("message-4").getBoundingClientRect().top).toBe(64)
// The reply streams in below the anchor. While it still fits in the
// spacer room the anchor holds and following stays off.
await rendered.rerender(
[
{ id: "message-1", height: 80 },
{ id: "message-2", height: 80 },
{ id: "message-3", height: 80 },
{ id: "message-4", height: 20, scrollAnchor: true },
{ id: "message-5", height: 8 },
],
{ autoScroll: true }
)
await triggerResize(rendered.content())
expect(rendered.viewport().scrollTop).toBe(176)
expect(rendered.message("message-4").getBoundingClientRect().top).toBe(64)
// Growth past the spacer means the reply has filled the viewport and the
// reader is at the live edge, so autoScroll takes over from the anchor.
rendered.message("message-5").dataset.testHeight = "60"
await triggerResize(rendered.content())
expect(rendered.viewport().scrollTop).toBe(220)
expect(rendered.state().end).toBe(false)
// Follow-output stays engaged for the rest of the stream.
rendered.message("message-5").dataset.testHeight = "100"
await triggerResize(rendered.content())
expect(rendered.viewport().scrollTop).toBe(260)
expect(rendered.state().end).toBe(false)
})
it("applies the default end target after async messages mount", async () => {
const rendered = await renderTestScroller({
messages: [],
@@ -716,6 +778,43 @@ describe("MessageScroller", () => {
expect(rendered.viewport().scrollTop).toBe(0)
})
it("keeps following when a state commit sees growth before the coalesced resize handler", async () => {
const rendered = await renderTestScroller({
autoScroll: true,
defaultScrollPosition: "end",
messages: [
{ id: "message-1", height: 80 },
{ id: "message-2", height: 80 },
{ id: "message-3", height: 80 },
],
})
expect(rendered.viewport().scrollTop).toBe(140)
// Let the opening scroll-to-end settle so the autoscrolling flag clears.
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
// A large streamed chunk grows the content past the live edge, and a
// scroll event commits state before the frame-coalesced resize handler has
// caught follow-output up. The reader did not move, so the commit must not
// release follow-bottom, and the gap it observed must not be published —
// that would strobe the scroll button once per chunk.
rendered.message("message-3").dataset.testHeight = "160"
await act(async () => {
rendered.viewport().dispatchEvent(new Event("scroll", { bubbles: true }))
})
expect(rendered.state().end).toBe(false)
expect(rendered.button().dataset.active).toBe("false")
await triggerResize(rendered.content())
expect(rendered.viewport().scrollTop).toBe(220)
expect(rendered.state().end).toBe(false)
})
it("places appended scroll anchors near the top with previous peek", async () => {
const rendered = await renderTestScroller({
messages: [

View File

@@ -73,6 +73,7 @@ function useMessageScrollerController({
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,
@@ -84,6 +85,7 @@ function useMessageScrollerController({
scrollMarginRef,
scrollPreviousItemPeekRef,
spacerGapRef,
spacerHeightRef,
spacerRef,
stateFrameRef,
stateStore,
@@ -133,9 +135,20 @@ function useMessageScrollerController({
// scroll so the auto-scroll animation cannot release itself. Arming also
// skips the anchored-to-message hold: the tail spacer makes a freshly
// anchored turn read as "at the end", and re-arming there would let the
// first streamed chunk yank the reader off the anchor.
// first streamed chunk yank the reader off the anchor. The hold hands back
// to following in handleResize, once the reply consumes the tail spacer.
const reconcileFollowMode = React.useCallback(
(scrollable: MessageScrollerScrollable) => {
const scrollTop = viewportRef.current?.scrollTop ?? 0
// Content growing past the live edge also reads as "not at the end", but
// only a scrollbar drag moves scrollTop up. Growth must not release
// follow-output: the resize handler is coalesced onto a frame, so a state
// commit can observe the grown content before follow catches up.
const scrolledUp =
scrollTop < lastScrollTopRef.current - SCROLL_POSITION_EPSILON
lastScrollTopRef.current = scrollTop
if (
autoScrollRef.current &&
!scrollable.end &&
@@ -146,6 +159,7 @@ function useMessageScrollerController({
} else if (
modeRef.current === "following-bottom" &&
scrollable.end &&
scrolledUp &&
!autoscrollingRef.current
) {
modeRef.current = "free-scrolling"
@@ -163,8 +177,19 @@ function useMessageScrollerController({
})
reconcileFollowMode(nextState)
writeStateAttributes(nextState)
stateStore.setSnapshot(nextState)
// While follow-output is engaged the scroller is already closing any gap a
// streamed chunk just opened, so publishing it as scrollable toward the
// end would strobe the scroll button once per chunk. Reconcile runs on the
// raw geometry first, so a commit that releases follow still publishes the
// gap it released over.
const publishedState =
modeRef.current === "following-bottom"
? { ...nextState, end: false }
: nextState
writeStateAttributes(publishedState)
stateStore.setSnapshot(publishedState)
}, [reconcileFollowMode, stateStore, writeStateAttributes])
const scheduleStateCommit = React.useCallback(() => {
@@ -477,7 +502,23 @@ function useMessageScrollerController({
// Hold the anchored turn in place as content below it resizes (a reply
// streaming in, or a transient marker collapsing) — otherwise the shrinking
// content lets the browser clamp scrollTop and the turn drops.
const previousSpacerHeight = spacerHeightRef.current
if (reanchorToAnchoredMessage()) {
// The reply streaming below the anchor consumes the tail spacer as it
// grows. Once the last of it is gone the reply has filled the viewport
// and the reader is genuinely at the live edge, so autoScroll hands off
// from the anchor hold to following the bottom. Requiring the >0 → 0
// transition keeps a turn taller than the viewport (placed with no
// spacer) held instead of yanked to the end.
if (
autoScrollRef.current &&
previousSpacerHeight > 0 &&
spacerHeightRef.current === 0
) {
scrollToEnd({ behavior: "auto" })
}
return
}

View File

@@ -26,6 +26,7 @@ type MessageScrollerRefs = {
defaultScrollPositionAppliedRef: React.RefObject<boolean>
firstItemRef: React.RefObject<HTMLElement | null>
itemCountRef: React.RefObject<number>
lastScrollTopRef: React.RefObject<number>
messageElementsRef: React.RefObject<Map<string, HTMLElement>>
modeRef: React.RefObject<MessageScrollerMode>
pendingScrollFrameRef: React.RefObject<number | null>
@@ -75,6 +76,9 @@ function useMessageScrollerRefs({
const scrollEdgeThresholdRef = React.useRef(scrollEdgeThreshold)
const itemCountRef = React.useRef(0)
const firstItemRef = React.useRef<HTMLElement | null>(null)
// The scrollTop seen by the previous state commit, so follow-release can tell
// a reader scrolling up from content growing past the live edge.
const lastScrollTopRef = React.useRef(0)
const modeRef = React.useRef<MessageScrollerMode>(
autoScroll ? "following-bottom" : "free-scrolling"
)
@@ -139,6 +143,7 @@ function useMessageScrollerRefs({
defaultScrollPositionAppliedRef,
firstItemRef,
itemCountRef,
lastScrollTopRef,
messageElementsRef,
modeRef,
pendingScrollFrameRef,