From 9052e5f55edaf933017969e816263d733ada5b0f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 03:41:10 -0400 Subject: [PATCH] Notice a row arriving, and stop subcomposing the list at the keyboard Two faults from making the retained window lazy, both of them mine. A screen of blank between the last message and the box it was typed in. The window was recomputed when the view had moved far enough, and a message arriving does not move the view -- so the new row fell outside the window and stood in as a spacer of its guessed height. The version number the check compares against is only bumped by the recompute it guards, so asking before refreshing meant never noticing. It refreshes first now, and the window also watches how many rows there are, because a row arriving is the case it exists to catch and the one that does not announce itself through the scroll position. And the keyboard, which was the most expensive thing on the screen. The visible height came from a `BoxWithConstraints` wrapped around the transcript -- that is a `SubcomposeLayout`, and the IME animation changes the visible height on every frame of its slide, so the entire transcript was being subcomposed again for each of them. The same number read in the layout phase, from the scroll container's own measurement, makes it a relayout instead, and the rows keep the measurements they already have. Checked on the emulator: the newest message sits against the composer with the keyboard up, and there is no gap. Co-Authored-By: Claude Opus 5 --- .../kotlin/com/example/aiapp/SessionScreen.kt | 6 +--- .../com/example/aiapp/TranscriptScroll.kt | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 27fce6a..5b6b9fe 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -8,7 +8,6 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -1415,13 +1414,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // is no frame in which the transcript is somewhere other than where it was left. val settled = !restoring && !listState.settling Box(Modifier.weight(1f).fillMaxWidth()) { - BoxWithConstraints(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { TranscriptColumn( rows = rows, state = listState, - // A scrollable child is measured with no height bound, so the content cannot - // ask how tall the visible area is; this is the only place that knows. - viewportHeight = maxHeight, contentPadding = TRANSCRIPT_PADDING, spacing = TRANSCRIPT_SPACING, modifier = 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 1a8d88c..779d33c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -9,7 +9,6 @@ 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 import androidx.compose.foundation.verticalScroll @@ -18,6 +17,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -28,6 +28,7 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -89,8 +90,13 @@ class TranscriptScroll(internal val scroll: ScrollState) { private var rowIndex = HashMap() private var topsStale = true + /** How many rows the list is drawing, so the window notices one arriving. */ + var rowCount: Int by mutableIntStateOf(0) + private set + internal fun laidOut(order: List, spacing: Int, padTop: Int) { this.order = order + rowCount = order.size this.spacing = spacing this.padTop = padTop rowIndex = HashMap(order.size) @@ -286,10 +292,16 @@ class TranscriptScroll(internal val scroll: ScrollState) { * either side and this moves it in steps of two, so the margin absorbs the staleness. */ internal fun trackRetained() { + // Before the comparison, not after it. Rows arriving is the case this exists to catch and + // it does not move the view: a message sent lands at the newest end, and if the range is + // not recomputed the new row is outside it and stands in as a spacer of its guessed height + // -- a screen of blank between the last message and the box it was typed in. The version + // it is compared against is only bumped by this call, so asking first meant never noticing. + refreshTops() val viewportTop = scroll.maxValue - scroll.value val step = (scroll.viewportSize * RETAIN_STEP_SCREENS).coerceAtLeast(1) val moved = viewportTop - rangeAt - if (retained.isEmpty() || moved > step || moved < -step || topsVersion != rangeVersion) { + if (retained.isEmpty() || topsVersion != rangeVersion || moved > step || moved < -step) { retained = retainedRange(viewportTop) } } @@ -410,7 +422,6 @@ fun rememberTranscriptScroll(key: Any?): TranscriptScroll { fun TranscriptColumn( rows: List, state: TranscriptScroll, - viewportHeight: Dp, contentPadding: PaddingValues, spacing: Dp, modifier: Modifier = Modifier, @@ -438,7 +449,20 @@ fun TranscriptColumn( modifier .verticalScroll(state.scroll, reverseScrolling = true) .padding(contentPadding) - .heightIn(min = viewportHeight) + // As tall as the visible area at least, so a conversation shorter than the screen sits + // against the composer rather than leaving a gap under it that cannot be scrolled away. + // + // Taken in the layout phase from the scroll container's own measurement, rather than + // from a `BoxWithConstraints` around this. That is a `SubcomposeLayout`, and the + // keyboard opening changes the visible height on every frame of its animation -- so + // the whole transcript was being subcomposed again for each of those frames, which is + // what made bringing the keyboard up cost more than anything else on the screen. Read + // here it is a relayout, and the rows keep the measurements they already have. + .layout { measurable, constraints -> + val placeable = + measurable.measure(constraints.copy(minHeight = state.scroll.viewportSize)) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } .fillMaxWidth() // Once for the whole list, not once per row: this is where a saved position is put // back, and by placement the scroll container's own measurements describe this layout.