The scroll-up freeze-then-skip was the history pager fighting the transcript's
own storage. A streamed reply is stored one token per event -- hundreds of
AssistantText events for one message -- but a page was counted in events, so
on a delta-heavy conversation a page was a fraction of one row: the opening
80-event load was less than a screen, "scroll up a bit" hit the unloaded
boundary at once, and each page the client did fetch cost a 400-event fold
(hundreds of thousands of list copies) that landed as one jarring insertion.
The server now joins each run of consecutive AssistantText deltas into the one
event the client's fold makes of it, and counts a page's limit in these
coalesced rows -- so a page is a page of the screen whatever the delta density.
Measured against a 3,500-event / 100-row echo session on the emulator: a raw
limit-20 page returns 20 tokens of one reply; the coalesced limit-20 returns
five whole replies. Scrolling the whole thing showed waited p99 51.7ms -> 0.6ms
and the worst whole-transcript measure 59ms -> ~0, with the client folding ~100
row-events instead of 3,500 token-events. No duplicate keys; the first reply
still reconstructs whole from token zero, so healSplitMessage welds the raw
newest window to the coalesced older pages exactly as before.
Coalescing is opt-in per request (`?coalesce=true`) and applied only to older
pages (`before` set): the newest window keeps real seqs because the live stream
resumes from the newest seq the phone applied, and a coalesced newest event
would hide the deltas after its first seq and replay them. The anchor-restore
path also stays raw -- it counts events to reach a known seq, which a page
measured in rows cannot do -- so HISTORY_PAGE is now rows while the restore
span and its cushion stay in events.
Also: ui-sandbox.sh gains a `keep` verb that restarts the server without wiping
sessions, so a fixture that costs minutes to build (a long delta-heavy
transcript) survives a server rebuild.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>