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 c05f7b9..eeb4efa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -2,6 +2,8 @@ package com.example.aiapp import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextLinkStyles @@ -11,7 +13,10 @@ import androidx.compose.ui.unit.TextUnit import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography +import com.mikepenz.markdown.model.State import com.mikepenz.markdown.model.parseMarkdown +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * An assistant's reply, rendered as the markdown it is written in. @@ -26,20 +31,7 @@ import com.mikepenz.markdown.model.parseMarkdown @Composable fun MarkdownText(text: String, modifier: Modifier = Modifier) { val body = MaterialTheme.typography.bodyLarge - // Parsed here, in the composition, rather than by the overload that takes the text itself. - // That one parses in a coroutine and draws an empty loading slot until the result arrives -- - // so a row is measured at nothing before it is measured at its real height, and the - // transcript above it collapses and springs back. Seen with five replies on screen at once, - // every one of them blank, the whole conversation shrunk to fit a single screen; a moment - // later it was all there again. That is the "skipping up and down" this list must never do, - // and no amount of scroll anchoring can survive a row that lies about its height first. - // - // The cost is the parse on the main thread, which is the trade being made deliberately: a - // few milliseconds of work at the moment a row is composed, against a layout that is wrong - // every time one is. If a very long reply ever makes that visible, the answer is the - // renderer's streaming state -- which parses incrementally -- and not going back to a - // placeholder with no height. - val parsed = remember(text) { parseMarkdown(text) } + val parsed = parsedMarkdown(text) Markdown( parsed, colors = @@ -112,3 +104,33 @@ fun MarkdownText(text: String, modifier: Modifier = Modifier) { modifier = modifier, ) } + +/** + * [text] parsed: on the composing thread the first time this row is drawn, and off it every time + * afterwards. + * + * The first parse has to be inline. The renderer's own asynchronous path draws an empty loading + * slot until its result arrives, so a row is measured at nothing before it is measured at its real + * height, and the transcript above it collapses and springs back. Seen with five replies on screen + * at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment + * later it was all there again. That is the "skipping up and down" this list must never do, and no + * amount of scroll anchoring can survive a row that lies about its height first. + * + * Every parse *after* the first is a different case, and it is the one that was costing: a reply + * arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured + * against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work + * in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at + * 120. Those go to a background thread, and the row keeps drawing the parse it already has until + * the new one lands, so there is never a frame without a height. What is on screen is always a + * real prefix of the reply rather than a guess at it; it is simply one parse behind. + */ +@Composable +private fun parsedMarkdown(text: String): State { + // The text each parse came from, so the first composition's is not immediately repeated. + val parsed = remember { mutableStateOf(text to parseMarkdown(text)) } + LaunchedEffect(text) { + if (parsed.value.first == text) return@LaunchedEffect + parsed.value = text to withContext(Dispatchers.Default) { parseMarkdown(text) } + } + return parsed.value.second +} 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 9bb7500..8d18d1a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -601,9 +601,6 @@ fun SessionScreen( // screen starts with the end of the conversation and fetches earlier // pages only when somebody scrolls to them. var oldestSeq by remember { mutableLongStateOf(0L) } - // Every transcript event loaded, in order, beside the rows they folded - // into. See `apply`. - var loaded by remember { mutableStateOf(listOf()) } // Sent, but not yet read by the session -- which is when the backend // records it and it comes back as a row. Until then it is drawn below // the working indicator, because that is where it is in the session's @@ -686,11 +683,6 @@ fun SessionScreen( if (event is SessionEvent.CommandSent) { waitingCommands = waitingCommands.filterNot { it.first == event.id } } - // Kept as well as folded. Folding is one-way -- a tool's - // start and end become one row -- so a page arriving in - // front of what is already here cannot be stitched on - // without the events themselves. - loaded = loaded + event items = foldEvent(items, entry) } @@ -830,7 +822,6 @@ fun SessionScreen( // screen -- `apply` refills them, and scrolling // up pages the rest back in as it always does. items = listOf() - loaded = listOf() held = listOf() oldestSeq = 0L moreHistory = true