From 718fb5320c6e444490ce6c523a8fe563cf8621f7 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 03:27:54 -0400 Subject: [PATCH] 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 --- .../com/example/aiapp/TranscriptScroll.kt | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt index bb81e4a..5db59d0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -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,17 +257,59 @@ 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..