Skip to main content
While an answer streams the transcript follows it to the bottom — but the moment you scroll up to re-read something, following stops and a jump-to-latest button appears instead of dragging you back down. transcript/scroll is called on every publish and from the scroll DOM event — the follow decision is data, not a scroll handler.

Quick Start

1

Seed the follow state

The app starts following, so the first tokens paint at the bottom.
2

Update on scroll

Only a scroll event changes whether the view is following.
3

React to new content

New content asks to be scrolled to; whether it is depends on the follow state.
ScrollMetrics is the three numbers every platform reports — scrollTop, scrollHeight, clientHeight — so scroll.ts needs no DOM and the same rule serves a React Native port.

The follow state machine

Following is on by default. A user scroll off the bottom turns it off; a tap on jump-to-latest turns it back on.
“At the bottom” is a threshold (FOLLOW_THRESHOLD_PX = 48, roughly one line), not an equality. Fractional device pixels, a rubber-band overscroll, and a settling keyboard all mean the exact equality never holds on a real device, so following would never engage without the tolerance.

The affordance

The jump-to-latest button lives inside the chat screen, hidden by default. Its visibility is driven entirely by shouldShowJumpToLatest(follow).
shouldShowJumpToLatest shows the button exactly when following is off — the user has scrolled up off the bottom of a streaming transcript.

The tap is a scroll decision, not an intent

The delegated click listener on root special-cases jump-latest before intentFrom, because it is a scroll decision, not an engine call — it never reaches the intent router.
jumpToLatest is the one place following may be turned back on without a scroll event, because the user asked for it. Routing the tap through intentFrom instead would send it to perform — where there is no matching intent and the scroll would never happen.

How It Works

The subtle part is that our own auto-scroll comes back as a scroll event a frame later. Counting outstanding auto-scrolls is what stops that correction — caught mid-flight at a position that is not yet the bottom — from being read as “the user scrolled up”.
This is the bug every streaming chat UI ships first: you scroll up to read, the next token arrives, and the view yanks you back. It is unfixable from inside a scroll handler because the handler cannot tell why the position changed — which is why the decision is modelled explicitly here.

Best Practices

Do not read scrollTop and decide in the renderer. Feed ScrollMetrics to onScroll / onContentChanged and apply the returned action — the pure function is where the auto-scroll counting lives.
Every scrollTo you apply comes back as a scroll event. Without pendingAutoScrolls, that echo unsticks the view and following works for two seconds, then stops for the rest of the answer.
It is a scroll decision, not an engine call. Special-case data-action="jump-latest" ahead of intentFrom so it never reaches the intent router.
Bind the affordance’s hidden to !shouldShowJumpToLatest(follow) after every follow update, so it appears exactly when following is off.

Composer Behavior

Draft persistence, autosize, and the Enter policy.

History & Reopen

Restored messages painted through the reconciler.

Overview

The retained chat screen and its transcript.

Shell & Adapters

Keyboard height and safe-area geometry.