Stop re-measuring the transcript at the keyboard, and seed the window

Three things, all of them the same shape: work done because something was
asked a question at the wrong moment.

The keyboard. The layout modifier passed `minHeight = viewportSize` down
to its child, so every frame of the IME animation changed the child's
constraints -- and changed constraints are exactly what defeats the
early-return in `MeasurePassDelegate.remeasure`. The whole transcript was
re-measured on the way up, measured on a Pixel 9 Pro XL as 250
measurements averaging 2.8ms with a 54.5ms worst. The minimum is applied
to what this node reports now, not to what it asks its child for, so the
child early-returns; the content is placed against the bottom to keep the
anchoring the minimum existed for. It only ever bites on a conversation
shorter than the screen, which was never the case paying for it.

The flicker. `retained` starts empty, so on the composition that
introduces the rows every one of them is outside the window, the whole
transcript collapses to a single spacer, and it draws blank for a frame.
Seeded now -- at the newest end on open, and around the anchor on a
restore. The restore case is the one that bites: `placed()` jumps the view
during placement, and a window recomputed there only schedules a
recomposition, so the destination would draw as spacer for a frame or two
after drawing ungates. Seeded by pixels rather than by a row count,
because a run of tool calls is eight rows and less than half a screen.

The block layers. Every paragraph of every reply had a layer, which was
right when whole rows were re-recording constantly and one row's display
list was 36,982px tall. Re-recording is rare now -- 65 whole rows in fifty
seconds of reading -- and a layer costs a layout node and a display list
held for the life of the row, against the node count the per-frame cost
scales with. They go to the message still arriving, which is the only one
whose drawing is invalidated often enough to want the granularity.

Verified on the emulator against the case none of this was written for: a
two-message conversation far shorter than the viewport still hangs from
the composer, with the keyboard both up (messages at y1756-1940, composer
2109) and down (936-1120 against 1289).

Mechanism for the block layers and the hole in the restore seeding both
from the ai-app-2-6d session; the layers are not re-positioned per frame
as I had it, they are baked into the row's display list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 09:24:19 -04:00
1 parent f497d1f3ad
commit 41df8bb76a
4 files changed
+109 -30

No files matched your search

@@ -26,18 +26,23 @@ import androidx.compose.ui.unit.dp
* seconds away, and a half-written marker is not a marker yet. * seconds away, and a half-written marker is not a marker yet.
*/ */
@Composable @Composable
fun AssistantMessage(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) { fun AssistantMessage(
text: String,
replies: ParsedReplies,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
DebugStats.count("message composed") DebugStats.count("message composed")
val parts = remember(text) { partsOf(text) } val parts = remember(text) { partsOf(text) }
val only = parts.singleOrNull() val only = parts.singleOrNull()
if (only is MessagePart.Prose) { if (only is MessagePart.Prose) {
BlockedMarkdown(only.text, replies, modifier) BlockedMarkdown(only.text, replies, modifier, live)
return return
} }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part -> parts.forEach { part ->
when (part) { when (part) {
is MessagePart.Prose -> BlockedMarkdown(part.text, replies) is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live)
is MessagePart.Remembered -> MemoryNote(part, replies) is MessagePart.Remembered -> MemoryNote(part, replies)
} }
} }
@@ -1,7 +1,6 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -48,15 +47,29 @@ fun markdownBlocks(text: String): List<String> {
private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE) private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE)
/** /**
* A reply drawn a block at a time, with the blocks that are off screen not drawn at all. * A reply drawn a block at a time.
* *
* Each block keeps its composition and its layout whichever way it is scrolled -- that is what * Each block keeps its composition and its layout whichever way it is scrolled -- that is what
* stops a message being rebuilt when somebody comes back to it -- and only the drawing is skipped. * stops a message being rebuilt when somebody comes back to it. The heights come from the blocks
* The heights come from the blocks themselves as they are measured, so the running total is the * themselves as they are measured, so the running total is the same arrangement the list uses one
* same arrangement the list uses one level up. * level up.
*
* [live] is the message currently arriving, and it is the only one that gets a layer per block. A
* layer buys one thing here: when drawing is invalidated, only the block that changed is
* re-recorded instead of the whole reply. That is worth a great deal while a reply is streaming,
* because every delta invalidates the message and a finished one can be twenty-five screens tall.
* It is worth nothing once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows
* were re-recorded 65 times in fifty seconds of reading -- and it is not free: each layer is a
* layout node and a display list held for the life of the row, and live node count is what the
* per-frame cost of the transcript scales with.
*/ */
@Composable @Composable
fun BlockedMarkdown(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) { fun BlockedMarkdown(
text: String,
replies: ParsedReplies,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
val blocks = remember(text) { replies.blocksOf(text) } val blocks = remember(text) { replies.blocksOf(text) }
if (blocks.size == 1) { if (blocks.size == 1) {
MarkdownText(blocks.first(), replies, modifier) MarkdownText(blocks.first(), replies, modifier)
@@ -64,21 +77,17 @@ fun BlockedMarkdown(text: String, replies: ParsedReplies, modifier: Modifier = M
} }
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) {
blocks.forEach { block -> blocks.forEach { block ->
Box( MarkdownText(
block,
replies,
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
// One layer per block, for the reason the row has one: a block's glyphs are .then(if (live) Modifier.graphicsLayer() else Modifier)
// recorded once and afterwards moved. Splitting the message is what makes each
// of those layers a paragraph rather than a whole reply, which is what bounds
// both the recording and the memory the display list costs.
.graphicsLayer()
.drawWithContent { .drawWithContent {
val started = System.nanoTime() val started = System.nanoTime()
drawContent() drawContent()
DebugStats.record("record: one block", System.nanoTime() - started) DebugStats.record("record: one block", System.nanoTime() - started)
} },
) { )
MarkdownText(block, replies)
}
} }
} }
} }
@@ -1493,7 +1493,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
images = item.images, images = item.images,
) )
is TranscriptItem.AssistantMsg -> is TranscriptItem.AssistantMsg ->
AssistantMessage(item.text, replies) // Only the last row can still be arriving, and only a row
// that is still arriving earns a layer per block; see
// [BlockedMarkdown].
AssistantMessage(
item.text,
replies,
live = row === rows.lastOrNull(),
)
is TranscriptItem.ToolRun -> is TranscriptItem.ToolRun ->
ToolCard( ToolCard(
tool = item, tool = item,
@@ -103,6 +103,37 @@ class TranscriptScroll(internal val scroll: ScrollState) {
rowIndex = HashMap(order.size) rowIndex = HashMap(order.size)
order.forEachIndexed { index, seq -> rowIndex[seq] = index } order.forEachIndexed { index, seq -> rowIndex[seq] = index }
topsStale = true topsStale = true
// Something has to be built before the first frame, or the first frame is blank. The window
// is worked out from the scroll position, and there is no scroll position until this has
// been laid out once -- so on the composition that introduces the rows, every one of them
// is outside an empty window, the whole transcript collapses to a single spacer, and the
// reader sees it flicker before the real window arrives a frame later. The newest end is
// the right guess because that is where the transcript opens; [restore] seeds the other
// case, where the reader is being put back somewhere else entirely.
if (retained.isEmpty() && order.isNotEmpty()) seedAround(order.lastIndex)
}
/**
* Builds a screenful of rows around [index], for the frames before there is a window.
*
* Sized in pixels rather than in rows, because a row is anything from a one-line note to a
* screenful and "the last eight" is a different amount of transcript every time -- a run of
* tool calls is eight rows and less than half a screen, which would seed a window too small to
* cover the viewport and flicker anyway, which is the thing being fixed.
*/
private fun seedAround(index: Int) {
if (order.isEmpty()) return
val budget = (scroll.viewportSize * SEED_SCREENS).coerceAtLeast(ROW_GUESS)
var first = index.coerceIn(0, order.lastIndex)
var last = first
var built = assumed(order[first])
while (built < budget && (first > 0 || last < order.lastIndex)) {
// Downwards first: an anchor names the row at the *top* of the viewport, so the rows
// after it are the ones the reader is about to be looking at.
if (last < order.lastIndex) built += assumed(order[++last])
if (built < budget && first > 0) built += assumed(order[--first])
}
retained = first..last
} }
internal fun height(seq: Long, height: Int) { internal fun height(seq: Long, height: Int) {
@@ -144,6 +175,12 @@ class TranscriptScroll(internal val scroll: ScrollState) {
* costing it. * costing it.
*/ */
private fun refreshTops() { private fun refreshTops() {
// These are offsets within the content, and the content is what the column measured -- so
// they are only positions on screen while the content is at least as tall as the viewport.
// Below that the layout modifier reports the viewport's height and places the content
// against the bottom of it, and every top here is short by the difference. Nothing depends
// on it today, because a conversation shorter than the screen is entirely retained and has
// nowhere to scroll to, but a reader of this arithmetic should know it is assuming that.
if (!topsStale) return if (!topsStale) return
val out = IntArray(order.size) val out = IntArray(order.size)
var y = padTop var y = padTop
@@ -209,6 +246,11 @@ class TranscriptScroll(internal val scroll: ScrollState) {
val rowTop = topOf(anchor.seq) ?: return val rowTop = topOf(anchor.seq) ?: return
scroll.dispatchRawDelta((scroll.maxValue - rowTop - anchor.offset - scroll.value).toFloat()) scroll.dispatchRawDelta((scroll.maxValue - rowTop - anchor.offset - scroll.value).toFloat())
pending = null pending = null
// The jump lands somewhere the window was not computed for, and this is the last chance
// before the frame that draws. It cannot build anything by itself -- writing state during
// placement only schedules a recomposition -- which is why [restore] seeds the destination
// in advance; this widens the seed to the full window rather than replacing it.
trackRetained()
} }
/** Where a row's top edge sits inside the content, or null if it has not been measured. */ /** Where a row's top edge sits inside the content, or null if it has not been measured. */
@@ -467,6 +509,12 @@ class TranscriptScroll(internal val scroll: ScrollState) {
*/ */
fun restore(anchor: ScrollAnchor) { fun restore(anchor: ScrollAnchor) {
pending = anchor pending = anchor
// The destination, built now rather than discovered after the jump. [placed] moves the
// view during placement, and a window recomputed there only schedules a recomposition --
// so the rows it would build arrive a frame after the frame that ungates drawing, and the
// reader is shown the place they were put back to as blank spacer before it fills in.
// Seeded here it is built by the composition that the jump is measured in.
rowIndex[anchor.seq]?.let { seedAround(it) }
} }
/** Draw where we are instead: the anchored row is not in this transcript any more. */ /** Draw where we are instead: the anchored row is not in this transcript any more. */
@@ -549,12 +597,16 @@ fun TranscriptColumn(
// As tall as the visible area at least, so a conversation shorter than the screen sits // 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. // 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 // Applied to what this node *reports* rather than to what it asks its child for, and
// from a `BoxWithConstraints` around this. That is a `SubcomposeLayout`, and the // that distinction is the whole cost of opening the keyboard. Passed down as a minimum
// keyboard opening changes the visible height on every frame of its animation -- so // height, it changed the child's constraints on every frame of the IME animation --
// the whole transcript was being subcomposed again for each of those frames, which is // and changed constraints are exactly what defeats the early-return in
// what made bringing the keyboard up cost more than anything else on the screen. Read // `MeasurePassDelegate.remeasure`, so the entire transcript was re-measured thirty
// here it is a relayout, and the rows keep the measurements they already have. // times on the way up. Measured on a Pixel 9 Pro XL as 250 measurements averaging
// 2.8ms with a 54.5ms worst. The child is measured with the constraints it already
// had, so it early-returns, and the minimum is applied here where it belongs. It only
// ever bites on a conversation shorter than the screen, which is not the case that was
// paying for it.
// //
// Timed in two halves because the frame's draw phase is where Compose's measurement // Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which half. // lands, and "draw is high while nothing is being recorded" does not say which half.
@@ -563,12 +615,13 @@ fun TranscriptColumn(
// has. // has.
.layout { measurable, constraints -> .layout { measurable, constraints ->
val started = System.nanoTime() val started = System.nanoTime()
val placeable = val placeable = measurable.measure(constraints)
measurable.measure(constraints.copy(minHeight = state.scroll.viewportSize))
DebugStats.record("measure: the whole transcript", System.nanoTime() - started) DebugStats.record("measure: the whole transcript", System.nanoTime() - started)
layout(placeable.width, placeable.height) { val height = maxOf(placeable.height, state.scroll.viewportSize)
layout(placeable.width, height) {
val placing = System.nanoTime() val placing = System.nanoTime()
placeable.place(0, 0) // From the bottom, because that is the end the conversation hangs from.
placeable.place(0, height - placeable.height)
DebugStats.record("place: the whole transcript", System.nanoTime() - placing) DebugStats.record("place: the whole transcript", System.nanoTime() - placing)
} }
} }
@@ -686,5 +739,10 @@ private const val RETAIN_STEP_SCREENS = 2
*/ */
private const val STAND_UP_PER_FRAME = 2 private const val STAND_UP_PER_FRAME = 2
/**
* How much is built before there is a scroll position to work a window out from; see `seedAround`.
*/
private const val SEED_SCREENS = 2
/** What a row is assumed to be worth before any of them have been measured. */ /** What a row is assumed to be worth before any of them have been measured. */
private const val ROW_GUESS = 800 private const val ROW_GUESS = 800