From 5b9941cfaa434a427c668ab03029e6b7404ae858 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 03:03:21 -0400 Subject: [PATCH] Tell Compose the transcript's rows are values, so it can leave them alone A page of history landing recomposed every loaded row and everything inside it -- 701 compositions for 148 rows in one of Iris's scrolls, which is four rebuilds of the whole visible transcript, markdown and all. That is what her `waited` at the 90th percentile was: the frame could not start because the thread was rebuilding rows whose content had not changed. Nothing was stopping Compose skipping them except that it could not prove it was safe to. Stability is inferred from a class's fields and a `List` field makes it assume the worst, so `TranscriptItem`, `TranscriptRow` and `ParsedReplies` were all treated as things that might change underneath a composable at any moment. They are not: a row is rebuilt from the transcript rather than edited, two rows describing the same events are equal, and the parse cache is keyed on the text it parsed. Saying so is the whole change. Measured on the emulator over the same scroll: 139 row compositions, and **15** of them rebuilt the message inside. The other 124 skipped straight past the markdown, which is where the cost was. The transcript's own draw came down with it, from 3.1ms mean to 1.2ms. The promise these annotations make has to stay true -- nothing described by them is mutated after it is built. It is not today, and the note on `TranscriptRow` says so where somebody adding a field will read it. Co-Authored-By: Claude Opus 5 --- .../src/main/kotlin/com/example/aiapp/Markdown.kt | 2 ++ .../main/kotlin/com/example/aiapp/MemoryNote.kt | 1 + .../main/kotlin/com/example/aiapp/SessionScreen.kt | 2 ++ .../src/main/kotlin/com/example/aiapp/ToolRows.kt | 14 ++++++++++++++ 4 files changed, 19 insertions(+) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index f9ae47b..1e226c5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -3,6 +3,7 @@ package com.example.aiapp import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -166,6 +167,7 @@ private fun parsedMarkdown(text: String, replies: ParsedReplies): State { * itself on the way to being finished. It is dropped with the screen, and emptied by the stream * reset that drops the rows it describes. */ +@Stable class ParsedReplies { private val parsed = ConcurrentHashMap() 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 d5a7808..995bdb8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp */ @Composable fun AssistantMessage(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) { + DebugStats.count("message composed") val parts = remember(text) { partsOf(text) } val only = parts.singleOrNull() if (only is MessagePart.Prose) { 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 ab78a69..27fce6a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -179,6 +180,7 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni * stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes * from the last seq seen, so there is no separate history fetch to drift from it. */ +@Immutable sealed class TranscriptItem { /** * The transcript sequence number this row started at, and its identity on screen. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index af8833f..69fb472 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +39,19 @@ import androidx.compose.ui.unit.dp * the transcript's own order is what paging and the event stream depend on, and one screen's idea * of "these belong together" must not reach back into it. */ +/** + * Immutable, and said so, because Compose cannot tell. + * + * A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing + * the same events are equal. Compose infers stability from a class's fields, and a `List` field -- + * which several of these carry -- makes it assume the worst, so every composable taking one + * recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded + * rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll, + * and that is what a page landing costs on top of the fetch itself. + * + * The promise this makes is real and has to stay true: nothing here is mutated after it is built. + */ +@Immutable sealed class TranscriptRow { /** * This row's identity in the list, which must survive everything that can happen to the row.