diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt index 995bdb8..f2c1a89 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -26,18 +26,23 @@ import androidx.compose.ui.unit.dp * seconds away, and a half-written marker is not a marker yet. */ @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") val parts = remember(text) { partsOf(text) } val only = parts.singleOrNull() if (only is MessagePart.Prose) { - BlockedMarkdown(only.text, replies, modifier) + BlockedMarkdown(only.text, replies, modifier, live) return } Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { parts.forEach { 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) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt index 66ce84e..bc4ffb1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt @@ -1,7 +1,6 @@ package com.example.aiapp import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -48,15 +47,29 @@ fun markdownBlocks(text: String): List { 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 - * stops a message being rebuilt when somebody comes back to it -- and only the drawing is skipped. - * The heights come from the blocks themselves as they are measured, so the running total is the - * same arrangement the list uses one level up. + * stops a message being rebuilt when somebody comes back to it. The heights come from the blocks + * themselves as they are measured, so the running total is the same arrangement the list uses one + * 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 -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) } if (blocks.size == 1) { 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)) { blocks.forEach { block -> - Box( + MarkdownText( + block, + replies, Modifier.fillMaxWidth() - // One layer per block, for the reason the row has one: a block's glyphs are - // 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() + .then(if (live) Modifier.graphicsLayer() else Modifier) .drawWithContent { val started = System.nanoTime() drawContent() DebugStats.record("record: one block", System.nanoTime() - started) - } - ) { - MarkdownText(block, replies) - } + }, + ) } } } 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 5b6b9fe..ea69f15 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1493,7 +1493,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () images = item.images, ) 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 -> ToolCard( tool = item, 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 b62cf9e..89b1d93 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -103,6 +103,37 @@ class TranscriptScroll(internal val scroll: ScrollState) { rowIndex = HashMap(order.size) order.forEachIndexed { index, seq -> rowIndex[seq] = index } 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) { @@ -144,6 +175,12 @@ class TranscriptScroll(internal val scroll: ScrollState) { * costing it. */ 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 val out = IntArray(order.size) var y = padTop @@ -209,6 +246,11 @@ class TranscriptScroll(internal val scroll: ScrollState) { val rowTop = topOf(anchor.seq) ?: return scroll.dispatchRawDelta((scroll.maxValue - rowTop - anchor.offset - scroll.value).toFloat()) 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. */ @@ -467,6 +509,12 @@ class TranscriptScroll(internal val scroll: ScrollState) { */ fun restore(anchor: ScrollAnchor) { 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. */ @@ -549,12 +597,16 @@ fun TranscriptColumn( // 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. + // Applied to what this node *reports* rather than to what it asks its child for, and + // that distinction is the whole cost of opening the keyboard. Passed down as a minimum + // height, it changed the child's constraints on every frame of the IME animation -- + // and changed constraints are exactly what defeats the early-return in + // `MeasurePassDelegate.remeasure`, so the entire transcript was re-measured thirty + // 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 // lands, and "draw is high while nothing is being recorded" does not say which half. @@ -563,12 +615,13 @@ fun TranscriptColumn( // has. .layout { measurable, constraints -> val started = System.nanoTime() - val placeable = - measurable.measure(constraints.copy(minHeight = state.scroll.viewportSize)) + val placeable = measurable.measure(constraints) 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() - 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) } } @@ -686,5 +739,10 @@ private const val RETAIN_STEP_SCREENS = 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. */ private const val ROW_GUESS = 800