Summary
In e2e-chatbot-app-next, the message list scrolls exactly once per turn — on status === 'submitted' — and never while the assistant is streaming. Any reply taller than the reserved headroom on the last message runs off the bottom of the viewport, and the user has to scroll by hand for the rest of the turn.
The <Conversation> wrapper (use-stick-to-bottom) looks like it should already handle this, but as currently nested it is inert: the element the library scrolls can never scroll.
Root cause
Three things combine.
1. The only auto-scroll is one-shot. client/src/components/messages.tsx L46-58 fires on submitted and nothing runs during streaming:
useEffect(() => {
if (status === 'submitted') {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
});
}
}, [status, messagesContainerRef]);
2. The element that actually scrolls is the outer div at messages.tsx L61-65 — a flex-1 child of the h-dvh flex-col wrapper in chat.tsx, carrying overflow-y-scroll.
3. use-stick-to-bottom@1.1.1 does not put its scroll ref on <StickToBottom>. StickToBottom renders a plain <div {...props}>. It is StickToBottom.Content that creates the scroll element:
<div ref={context.scrollRef} style={{ height: '100%', width: '100%' }}>
<div ref={context.contentRef} {...props}>{children}</div>
</div>
That inner height: 100% div is the library's scroll target. Its ancestors — <Conversation>, then the outer scroller — have content-derived height, so height: 100% resolves against an indefinite containing block and computes to auto. The div sizes to its content, scrollHeight always equals clientHeight, and every scrollTop the library writes is a no-op. initial="smooth" and resize="smooth" never take effect.
The library does apply overflow: auto to that div in a layout effect when computed overflow is visible, but that does not help while the height is content-derived.
Additionally, overflowAnchor: 'none' on the outer scroller (messages.tsx L64) disables native CSS scroll anchoring, so there is no browser-level fallback either.
Why it is not always obvious
client/src/components/message.tsx L146 gives the last assistant message min-h-96 when requiresScrollPadding is set. Replies shorter than roughly 384px fit inside that reserved space and never need scrolling at all. The bug surfaces on long replies and on anything that renders tall blocks — tables, charts, long code.
Reproduction
- Run
e2e-chatbot-app-next.
- Ask for a long answer, e.g. "write 400 words about X".
- After the initial jump on send, the viewport stays put while tokens stream past the bottom edge.
Versions
- Template
e2e-chatbot-app-next at ab30424be796009d34a0b760c25395211fe08e91
use-stick-to-bottom@^1.1.1
Possible directions
(a) Make the library work as designed — give the chain a definite height so StickToBottom.Content's scroll element can actually scroll (for example drop overflow-y-scroll from the outer div and let Conversation be the real scroller with min-h-0 / h-full). Note this moves the real scroller away from useScrollToBottom's containerRef, so isAtBottom and the scroll-to-bottom button would need to move to useStickToBottomContext().
(b) Keep the outer scroller and drive it directly — a ResizeObserver on the content that pins to the bottom while following, released by a deliberate upward gesture (wheel-up / touchmove / PageUp) and re-armed once the user returns to the bottom. Worth noting that the scroll write should be instant rather than smooth: a smooth scrollTo re-issued on every chunk cancels the previous animation and leaves the viewport permanently trailing the text.
Happy to send a PR for whichever direction you prefer.
Summary
In
e2e-chatbot-app-next, the message list scrolls exactly once per turn — onstatus === 'submitted'— and never while the assistant is streaming. Any reply taller than the reserved headroom on the last message runs off the bottom of the viewport, and the user has to scroll by hand for the rest of the turn.The
<Conversation>wrapper (use-stick-to-bottom) looks like it should already handle this, but as currently nested it is inert: the element the library scrolls can never scroll.Root cause
Three things combine.
1. The only auto-scroll is one-shot.
client/src/components/messages.tsxL46-58 fires onsubmittedand nothing runs duringstreaming:2. The element that actually scrolls is the outer div at
messages.tsxL61-65 — aflex-1child of theh-dvh flex-colwrapper inchat.tsx, carryingoverflow-y-scroll.3.
use-stick-to-bottom@1.1.1does not put its scroll ref on<StickToBottom>.StickToBottomrenders a plain<div {...props}>. It isStickToBottom.Contentthat creates the scroll element:That inner
height: 100%div is the library's scroll target. Its ancestors —<Conversation>, then the outer scroller — have content-derived height, soheight: 100%resolves against an indefinite containing block and computes toauto. The div sizes to its content,scrollHeightalways equalsclientHeight, and everyscrollTopthe library writes is a no-op.initial="smooth"andresize="smooth"never take effect.The library does apply
overflow: autoto that div in a layout effect when computed overflow isvisible, but that does not help while the height is content-derived.Additionally,
overflowAnchor: 'none'on the outer scroller (messages.tsxL64) disables native CSS scroll anchoring, so there is no browser-level fallback either.Why it is not always obvious
client/src/components/message.tsxL146 gives the last assistant messagemin-h-96whenrequiresScrollPaddingis set. Replies shorter than roughly 384px fit inside that reserved space and never need scrolling at all. The bug surfaces on long replies and on anything that renders tall blocks — tables, charts, long code.Reproduction
e2e-chatbot-app-next.Versions
e2e-chatbot-app-nextatab30424be796009d34a0b760c25395211fe08e91use-stick-to-bottom@^1.1.1Possible directions
(a) Make the library work as designed — give the chain a definite height so
StickToBottom.Content's scroll element can actually scroll (for example dropoverflow-y-scrollfrom the outer div and letConversationbe the real scroller withmin-h-0/h-full). Note this moves the real scroller away fromuseScrollToBottom'scontainerRef, soisAtBottomand the scroll-to-bottom button would need to move touseStickToBottomContext().(b) Keep the outer scroller and drive it directly — a
ResizeObserveron the content that pins to the bottom while following, released by a deliberate upward gesture (wheel-up /touchmove/ PageUp) and re-armed once the user returns to the bottom. Worth noting that the scroll write should be instant rather than smooth: a smoothscrollTore-issued on every chunk cancels the previous animation and leaves the viewport permanently trailing the text.Happy to send a PR for whichever direction you prefer.