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 76fcf92..30536c1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -14,6 +16,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -256,6 +259,38 @@ class TranscriptScroll(internal val scroll: ScrollState) { return tops.getOrNull(index) } + /** + * Whether the row named by [seq] is close enough to be worth keeping composed at all. + * + * The window that could not be avoided. Keeping every loaded row alive is what makes scrolling + * back over a message cost nothing, and it is also the thing whose cost grows with the + * conversation rather than with the screen -- measured on a Pixel 9 Pro XL as a step: smooth + * with one page loaded, worse at the next, worse again at the one after, with the frame going + * into the draw phase while almost nothing was being recorded. + * + * Eight screens either side is about sixteen times what a lazy list keeps, which is the whole + * point: everything somebody has just read stays built, and only a deliberate journey back + * through the conversation pays to rebuild anything. A row outside it is replaced by a spacer + * of the height it was last measured at, so the transcript's total height does not change and + * nothing under the reader moves. + * + * A row that has never been measured has no height to stand in for it and is kept whatever its + * distance -- that is a row that has just been paged in, and it is about to be looked at. + */ + fun retains(seq: Long): Boolean { + val index = rowIndex[seq] ?: return true + val height = heights[seq] ?: return true + refreshTops() + val top = tops.getOrNull(index) ?: return true + val viewportTop = scroll.maxValue - scroll.value + val margin = scroll.viewportSize * RETAIN_SCREENS + return top + height >= viewportTop - margin && + top <= viewportTop + scroll.viewportSize + margin + } + + /** The height a row was last measured at, for the spacer that stands in for it. */ + fun heightOf(seq: Long): Int = heights[seq] ?: 0 + /** Whether a span of content, in content coordinates, is within a screen of the viewport. */ private fun near(top: Int, height: Int): Boolean { val viewportTop = scroll.maxValue - scroll.value @@ -385,32 +420,7 @@ fun TranscriptColumn( rows.forEach { item -> // Keyed so that a row keeps its composition -- and so the state inside it, an open // tool call, stays with the row rather than with the position. - key(item.key) { - Column( - Modifier.fillMaxWidth() - .onSizeChanged { state.height(item.startSeq, it.height) } - // Composed and measured whether or not it is drawn; see - // [TranscriptScroll.onScreen]. The check reads the scroll position from - // the draw phase, so moving the list invalidates drawing and nothing else. - // Timed as well as counted. Counting said what was skipped, and the - // answer stopped being useful the moment almost everything was: 4,090 rows - // drawn against 94,815 skipped, and drawing still took 30.9ms. A timer - // says which of the two remaining answers is true -- that the little being - // drawn is somehow expensive, or that the time is not in the transcript at - // all and every count here is beside the point. - .drawWithContent { - if (!state.onScreen(item.startSeq)) return@drawWithContent - val started = System.nanoTime() - drawContent() - DebugStats.record("draw: one row", System.nanoTime() - started) - } - ) { - // So a block of a long reply can ask the same question the row just answered, - // about its own part of it; see [RowWindow]. - val window = remember(state, item.startSeq) { state.windowFor(item.startSeq) } - CompositionLocalProvider(LocalRowWindow provides window) { row(item) } - } - } + key(item.key) { RetainedRow(state, item, row) } } below() } @@ -420,3 +430,53 @@ fun TranscriptColumn( val TRANSCRIPT_SPACING: Dp = 8.dp val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp) + +/** + * One row, kept built while it is near the screen and stood in for by its own height when it is + * not. + * + * A composable of its own rather than a block inside the list, and that is what makes the window + * affordable: whether a row is retained is read here, so Compose can invalidate this row alone when + * the answer changes. Read from the list's own body instead, every row would recompose every time + * any row crossed the edge of the window. + */ +@Composable +private fun RetainedRow( + state: TranscriptScroll, + item: TranscriptRow, + row: @Composable (TranscriptRow) -> Unit, +) { + // Derived, so a row hears about the scroll only when its own answer changes rather than on + // every frame; see [TranscriptScroll.retains]. + val retained by + remember(state, item.startSeq) { derivedStateOf { state.retains(item.startSeq) } } + if (!retained) { + DebugStats.count("row stood down") + Spacer( + Modifier.fillMaxWidth() + .height(with(LocalDensity.current) { state.heightOf(item.startSeq).toDp() }) + ) + return + } + Column( + Modifier.fillMaxWidth() + .onSizeChanged { state.height(item.startSeq, it.height) } + // Composed and measured whether or not it is drawn; see [TranscriptScroll.onScreen]. + // The check reads the scroll position from the draw phase, so moving the list + // invalidates drawing and nothing else. + .drawWithContent { + if (!state.onScreen(item.startSeq)) return@drawWithContent + val started = System.nanoTime() + drawContent() + DebugStats.record("draw: one row", System.nanoTime() - started) + } + ) { + // So a block of a long reply can ask the same question the row just answered, about its + // own part of it; see [RowWindow]. + val window = remember(state, item.startSeq) { state.windowFor(item.startSeq) } + CompositionLocalProvider(LocalRowWindow provides window) { row(item) } + } +} + +/** How far either side of the screen a row stays built; see [TranscriptScroll.retains]. */ +private const val RETAIN_SCREENS = 8