feat(chat): block-level streaming reveal with a gliding follow

Token-by-token streaming mutates the trailing paragraph in place on
every tick: words rewrap, the last line jitters, and the whole reply
reads as flicker. Streamed text now commits only up to the last complete
line — prose arrives a paragraph at a time (a markdown paragraph is one
logical line), code fences reveal line by line, tables row by row — and
a shown block never changes again. A paragraph that runs long without a
newline releases at the last sentence (then word) boundary so the stream
never stalls. Applies to assistant text and reasoning; tool output keeps
its raw tail.

With growth arriving in block steps, the end follow switches to the
list's animated mode so each step is a glide — reveal and scroll read as
one continuous motion. The gesture opt-out now measures at-end from the
live list state instead of the cached flag, which the animated glide
deliberately leaves stale while trailing the edge; without that, a drag
during a glide could leave the scroll-to-bottom pill unshown.

Measured: end-following holds at distance 0 for the whole stream, and
the mobile drag opt-out shows the pill in three of three runs. The
continuous glide costs ~15% more main-thread time per streamed character
than the instant follow — the price of the motion.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 18:16:49 +03:00
parent b946fc7083
commit 3b8ed8fc36
6 changed files with 112 additions and 6 deletions
@@ -1071,7 +1071,10 @@ const TimelineList = React.memo(({
// resize settles.
maintainScrollAtEnd={anchoredEndSpace || !streamingAutoFollowEnabled || isWidthResizing || endPinningReleased
? false
: { animated: false, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }}
// Animated: with block-level reveal the content grows in
// paragraph/line steps, and the animated follow turns each
// step into a glide — reveal and scroll read as one motion.
: { animated: true, on: { dataChange: true, itemLayout: true, layout: true, footerLayout: true } }}
// Prepending older history must not move what the user is
// reading. Size restoration applies only during a width
// resize — see the observer above.