Stop the transcript being able to go blank

Scrolling up emptied the screen and only reopening the session brought
it back. The guess for an unmeasured row's height was re-derived from a
running average each time it was wanted, and that average moves as rows
are measured -- so the height a spacer had been *built* at stopped
matching the height the running totals were adding up, and the two drifted
apart. Once they differed by more than the retain window every row failed
the distance test at once, and a transcript of nothing but spacers has
nothing left to measure and so nothing to correct itself with.

Two changes, and the second is the one that matters. A guess is now made
once per row and kept, so it cannot drift from what was built with it.
And which rows stay built is decided as a *range* rather than by each row
testing itself: the row nearest the viewport is in that range by
construction, whatever the arithmetic says, so the worst a mistake here
can do is build too few rows or too many. A blank transcript is no longer
a state this can reach.

That is the shape worth keeping from the bug. Every row answering
independently meant one wrong number could stand all of them down
together, and the failure was silent, self-sustaining, and looked exactly
like the screen having nothing to show.

Checked by scrolling sixty swipes to the oldest loaded end and back --
the content holds throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 03:27:54 -04:00
1 parent 53735f8df4
commit 718fb5320c
1 file changed
+50 -7
@@ -145,6 +145,7 @@ class TranscriptScroll(internal val scroll: ScrollState) {
}
tops = out
topsStale = false
topsVersion++
}
/** Where the last touch went down, in the content's own coordinates. */
@@ -256,16 +257,58 @@ class TranscriptScroll(internal val scroll: ScrollState) {
* made a page of history land as one hundred-millisecond frame -- seventeen screens of markdown
* shaped at once, because "not measured yet" described the whole page.
*/
fun retains(seq: Long): Boolean {
val index = rowIndex[seq] ?: return true
val height = assumed(seq)
fun retains(seq: Long): Boolean = rowIndex[seq]?.let { it in retainedRange() } ?: true
/**
* Which rows stay built, as a range of indices rather than a test each row makes for itself.
*
* A range can be guaranteed non-empty and a per-row test cannot, which is the point. Every row
* answering independently means a fault in the arithmetic stands *all* of them down at once and
* leaves a blank transcript with nothing measured, so nothing to correct it -- which is exactly
* what happened. Here the nearest row to the viewport is in the range by construction, whatever
* the numbers say, so the worst a mistake can do is build too few rows or too many.
*/
private fun retainedRange(): IntRange {
refreshTops()
val top = tops.getOrNull(index) ?: return true
if (order.isEmpty()) return IntRange.EMPTY
val viewportTop = scroll.maxValue - scroll.value
val margin = scroll.viewportSize * RETAIN_SCREENS
return top + height >= viewportTop - margin &&
top <= viewportTop + scroll.viewportSize + margin
if (viewportTop == rangeAt && topsVersion == rangeVersion) return rangeFrom..rangeTo
val margin = (scroll.viewportSize * RETAIN_SCREENS).coerceAtLeast(1)
val from = viewportTop - margin
val to = viewportTop + scroll.viewportSize + margin
var first = -1
var last = -1
var nearest = 0
var nearestGap = Int.MAX_VALUE
order.forEachIndexed { index, seq ->
val top = tops[index]
val bottom = top + assumed(seq)
if (bottom >= from && top <= to) {
if (first < 0) first = index
last = index
}
val gap = if (bottom < viewportTop) viewportTop - bottom else top - viewportTop
if (gap in 0..<nearestGap) {
nearestGap = gap
nearest = index
}
}
if (first < 0) {
first = nearest
last = nearest
}
rangeFrom = first
rangeTo = last
rangeAt = viewportTop
rangeVersion = topsVersion
return first..last
}
private var rangeFrom = 0
private var rangeTo = -1
private var rangeAt = Int.MIN_VALUE
private var rangeVersion = -1
private var topsVersion = 0
/** The height a row was last measured at, for the spacer that stands in for it. */
fun heightOf(seq: Long): Int = assumed(seq)