mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-08 14:35:09 +00:00
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:
5
.changeset/message-scroller-anchor-follow-handoff.md
Normal file
5
.changeset/message-scroller-anchor-follow-handoff.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ function CommandMenu() {
|
||||
{userMessages.map((message) => (
|
||||
<DropdownMenuItem
|
||||
key={message.id}
|
||||
onSelect={() =>
|
||||
onClick={() =>
|
||||
scrollToMessage(message.id, {
|
||||
align: "start",
|
||||
behavior: "smooth",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user