Following a streaming transcript without fighting the reader
A follow controller for agent transcripts that keeps reader intent as explicit state, separate from scroll geometry, and the cases that shaped it over two months.

An agent transcript moves constantly, and most of that movement has nothing to do with the person reading it. Tokens arrive, tool cards expand, activity rollups fold themselves on a timer, a “Working…” placeholder disappears, and a large block can mount late and tall. At the same time the person may be scrolling back to check something the agent said, selecting a line to copy or opening an earlier tool result. A view that follows new output has to keep up with the first kind of change without overriding the second, and the difficulty is that the browser reports both through the same few numbers.
The common approach is to work from geometry: measure the distance from the bottom, keep following while it is small, and assume the reader has left when it is large. On a transcript like this that fails in both directions. A tall block that mounts late makes the distance spike, so someone who has touched nothing looks as if they have scrolled away and the view stops following. A “Working…” stub that unmounts makes the content shorter, the browser clamps scrollTop, and now the scroll position has moved with no gesture behind it. You can keep adding tolerances and timers, but the numbers do not contain the answer. A scroll position records where the viewport is, and nothing about why it got there.
In June 2026 I pulled our follow behavior out into its own controller built around that observation, with coding agents helping on the implementation. Intent became explicit state, set by gestures, and geometry became a separate input that was only consulted under rules depending on that state. The controller had three states, idle, following and broken, and four calls: report a frame of measured growth, along with whether a turn was live and the current distance from the bottom; report an explicit break by the user; release after the turn had settled; and reset on a thread switch. It was a closure over one string with no access to the DOM, which meant the awkward sequences could be exercised in ordinary tests without a browser.
The two thresholds only make sense once those states are separate. From idle, a live turn started following if the reader was within 80 pixels of the bottom. That is the generous case: somebody at or near the end of the transcript when the agent starts talking wants to see what comes next, and a little slack costs nothing. From broken, the threshold dropped to 8 pixels. One step of our keyboard navigation could move only 16 to 32 pixels, which leaves you well inside the 80 pixel zone, so with a single shared threshold a reader who moved upward once would be pulled straight back down by the next frame of output. At 8 pixels, re-engaging required actually returning to the bottom, which is the only thing that can reasonably be read as asking to follow again.
Deciding what counts as a break takes more care than the state machine does. A wheel event broke the follow only when it scrolled upward. The upward keys were PageUp, Home, ArrowUp and Shift+Space. A pointer press during a live turn broke it only when its target was the scroller itself, which is how a grab of the scrollbar shows up, so pressing a control on a card did not detach you; once the turn had settled, any click did. A touch that moved broke it and a bare tap did not. A selection change broke it when a non-collapsed selection was anchored inside the scroller, since text that scrolls away while you are trying to select it is about as irritating as this kind of bug gets. All of these took effect synchronously in the gesture’s own handler, so no growth frame could run between the gesture and the break.
The list also had to cover input that never reached it by the normal route. Our window-level hotkey dispatcher ran in the capture phase and stopped propagation, so the transcript’s listeners never saw those keys and the scroll helpers had to announce themselves with a custom event. Native keyboard scrolling, where the browser handles the key itself, was not covered until August, two months after the controller was written. That is the cost of this design, and it should be counted. A geometry heuristic misreads every input in the same few ways, whereas an explicit list is right about what it knows and wrong about what it doesn’t, and you find the gaps when somebody gets yanked to the bottom.
Breaking the follow also turned out not to be enough to keep the viewport still. On the day I extracted the controller, I also fixed the case of a reader who had broken away and was still being dragged along by growth in the last row, and the controller was not the cause. TanStack Virtual, which virtualized the list, compensates the scroll position when a row that starts above the viewport changes size. For an old row that is exactly what you want, because it keeps what you are reading in place. But when you are reading inside a streaming tail row taller than the viewport, that row also starts above the viewport, so each increment of growth moved the scroll offset and the reader traveled with the output, in a state that claimed to be broken. The fix overrode compensation so that the tail row never compensated during a live turn or the linger after it. The general point is that anything else in the stack that writes to the scroll position can undo what the controller has decided, and it has to be brought under the same rule.
Shrinkage is the mirror image, and it arrives with less warning. Growth at least comes with an updated item, but activity cards fold on a timer, without a new event or prop reaching the list in the same frame. The collapsing regions announced a measured layout change, whose handler preserved the bottom anchor before paint while the controller was following and did nothing once follow was broken. Separately, the render-time pin needed a convergence guard: a new content commit could arm a write, but subsequent measurement renders only pinned when the height exceeded the largest value already seen. A smaller measurement alone did not trigger another write. Otherwise pinning could change which rows were measured, alter the height again and start a scroll feedback loop. Those were two different jobs: respond to an explicit card transition, and avoid chasing every fluctuation in the virtualizer’s estimates.
The end of a turn is not the end of layout either. The activity rollup collapsed four seconds after a turn finished, so the follow lingered for six, which carried it past that last fold before it released. Release returned a following view to idle, but it did not clear a break. If you had scrolled away during a turn, the turn ending did not bring you back.
Getting back was your move. The jump-to-latest control ran a 260 millisecond ease-out tween whose target was recomputed on every frame, because the bottom keeps moving while you travel to it. Wheel input, touch movement, pointer presses and our keyboard-scroll helpers aborted it, and with reduced motion enabled it jumped at once. Its duration and easing came from the Codex desktop renderer.
In late August the classic web client was replaced by a new one, whose initial version a coding agent wrote with me as co-author, built from shadcn/ui and AI SDK Elements over the same shared state. Follow in that client belonged to an upstream MessageScroller component rather than this controller. It reused the classifier for upward scroll keys and the user-scroll event to arm the pager that loads history. The cases above still gave us concrete requirements for a replacement scroller: a reader who had broken away should stay put as the tail grew, and selecting text should stop the follow.
That is why I think of the list of cases as the durable part of this work. The file that holds the controller is about 400 lines. The list took from June to August to find, and it applies to any transcript view that follows a stream, whichever component ends up owning the scroll.
Chris Chabot, November 2026