From 80aaf286c23f575856c21ad2da5f0afcfaa06345 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 1 Sep 2026 15:20:24 -0400 Subject: [PATCH] Draw a peer message a block at a time, and parse it off the drawing thread A message from another agent was the one markdown in the app still rendered whole: one parse and one display list for the entire thing. Every settled reply has been cut into blocks since the transcript was made lazy, and `warm` has been making those parses ahead on a background thread -- but it filtered for assistant replies alone, so the longest message a transcript holds was also the only one parsed on the thread that draws. Measured on the emulator against a 43KB peer message, opening it: 177ms in `markdown parsed while composing`, against none afterwards and 156 blocks already ready. What is left is the card being a single list item, so all 156 blocks are still measured, placed and recorded at once -- 118ms of placement in that same frame. The `when` in `warm` is now the rule rather than a filter: every row that draws markdown belongs in it. Blocks are spaced by the transcript's own BLOCK_SPACING rather than the renderer's internal padding, which moves a heading about 6px (2.3dp) closer to the paragraph above it. The message's total height is unchanged, and it now matches every reply in the transcript. While here: FrameStats was remembered per session screen and DebugStats is a global emptied only by the copy button, so the two halves of a render report covered different stretches of time -- and `drawAccounting` divides one by the other. A report copied after visiting two sessions claimed 36.8 seconds of placement inside a 13.5 second window, and clamped "everything else" to 0.00ms (0%), which reads as a screen whose entire cost is this app's code. One FrameStats for the app, so both halves mean "since this was last copied". Co-Authored-By: Claude Opus 5 --- .../kotlin/com/example/aiapp/FrameStats.kt | 29 +++++++----- .../kotlin/com/example/aiapp/PeerMessage.kt | 6 ++- .../kotlin/com/example/aiapp/SessionScreen.kt | 8 ++-- .../com/example/aiapp/TranscriptItems.kt | 46 ++++++++++++------- 4 files changed, 56 insertions(+), 33 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt index b6dd8b3..48ad26e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt @@ -10,7 +10,6 @@ import android.view.FrameMetrics import android.view.Window import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext /** @@ -26,8 +25,15 @@ import androidx.compose.ui.platform.LocalContext * The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds, * broken down into the parts the UI thread is responsible for -- handling input, running * animations, measuring and laying out, recording the draw -- and the parts after it. + * + * One of these for the app, like [DebugStats], because the two are read as one report and + * [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session + * and the counters were not, so a report copied after visiting two sessions divided every session's + * work by the newest one's frame count -- and printed the result as a per-frame measurement. It + * said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at + * 0.00ms (0%), which reads as a screen whose whole cost is this app's own code. */ -class FrameStats { +object FrameStats { private val total = ArrayList() private val waited = ArrayList() private val input = ArrayList() @@ -108,28 +114,28 @@ class FrameStats { } private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole) - - private companion object { - /** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */ - const val CAP = 20_000 - } } +/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */ +private const val CAP = 20_000 + /** - * Frame timings for as long as this screen is on it. + * Records into [FrameStats] for as long as this screen is on it. + * + * The listener is what comes and goes; what it writes into does not, so a report covers the same + * stretch of time as the counters beside it. See [FrameStats]. * * The listener is handed its own thread because the platform calls it for every frame and the * documentation is explicit that doing that on the main thread taxes the very thing being measured. */ @Composable -fun rememberFrameStats(): FrameStats { - val stats = remember { FrameStats() } +fun recordFrames() { val window = LocalContext.current.activity()?.window DisposableEffect(window) { if (window == null) return@DisposableEffect onDispose {} val thread = HandlerThread("frame-stats").apply { start() } val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ -> - stats.add(metrics) + FrameStats.add(metrics) } window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper)) onDispose { @@ -137,7 +143,6 @@ fun rememberFrameStats(): FrameStats { thread.quitSafely() } } - return stats } /** The activity behind a composable's context, which is what owns the window. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index 7d3b277..fe62c15 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -27,6 +27,10 @@ import androidx.compose.ui.unit.dp * Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a * transcript that puts it in their voice is making a claim about who asked for the work that * follows -- which is exactly the question a peer message is usually the answer to. + * + * Opened, it is drawn a block at a time ([BlockedMarkdown]) for the reason every reply already is: + * these are the longest messages a transcript holds, and one of them as a single render is one + * parse and one display list proportional to the whole of it. See [markdownBlocks]. */ @Composable fun PeerMessageRow( @@ -52,7 +56,7 @@ fun PeerMessageRow( ) } } - if (expanded) MarkdownText(item.text, replies, Modifier.padding(top = 6.dp)) + if (expanded) BlockedMarkdown(item.text, replies, Modifier.padding(top = 6.dp)) } } } 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 837612b..685f57f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1049,7 +1049,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // One poll for this machine's limits, read by the two things that show them: the bar under // the header, and the colour of the button that opens the dialog. val usage = rememberSessionUsage(settings, summary.setup) - val frames = rememberFrameStats() + recordFrames() var usageOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) } @@ -1150,9 +1150,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () " ${expandedTools.size} tool calls and" + " ${expandedGroups.size} groups open", ), - frames = frames.lines(context.refreshHz()), + frames = FrameStats.lines(context.refreshHz()), accounting = - frames.drawPhase().let { (nanos, count) -> + FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) }, crash = lastCrash(context), @@ -1170,7 +1170,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // Emptied by the copy, so pressing it twice measures two separate // stretches // of scrolling rather than one and then the same one again. - frames.reset() + FrameStats.reset() DebugStats.reset() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT) .show() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index d624fa2..f650b7b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -466,32 +466,46 @@ private fun updateTool( private val parsingThreads = Dispatchers.Default.limitedParallelism(2) /** - * Parses the replies among [rows], off whatever thread is drawing. + * Parses the markdown among [rows], off whatever thread is drawing. * * Called where a page of transcript is folded rather than where a row is composed, which is the * whole point: the work happens seconds before the reader reaches the rows it was done for. See * [ParsedReplies]. * * What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory - * note whole -- because a string warmed under a key no row ever looks up is a miss that nothing - * reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same - * [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] caches the flatten does, so a message is - * scanned once however many pages hand it back through here, while the whole loaded transcript - * crosses this on every page. + * note whole, a peer message split the same way prose is -- because a string warmed under a key no + * row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten + * this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] + * caches the flatten does, so a message is scanned once however many pages hand it back through + * here, while the whole loaded transcript crosses this on every page. + * + * Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer + * message was missing: this used to filter for assistant replies alone, so the one row type nobody + * had thought about paid its whole parse in the frame it appeared in, with no counter saying which + * row it was. */ suspend fun warm(replies: ParsedReplies, rows: List) { withContext(parsingThreads) { - val texts = - rows - .filterIsInstance() - .flatMap { replies.partsOf(it.text) } - .flatMap { part -> - when (part) { - is MessagePart.Prose -> replies.blocksOf(part.text) - // Drawn as one MarkdownText, so its whole text is the key looked up. - is MessagePart.Remembered -> listOf(part.text) + val texts = rows.flatMap { row -> + when (row) { + is TranscriptItem.AssistantMsg -> + replies.partsOf(row.text).flatMap { part -> + when (part) { + is MessagePart.Prose -> replies.blocksOf(part.text) + // Drawn as one MarkdownText, so its whole text is the key + // looked up. + is MessagePart.Remembered -> listOf(part.text) + } } - } + // A message from another agent is markdown too, and it is the longest thing + // in a transcript often enough that leaving it out was the whole of why one + // cost a fifth of a second to open: it was the only markdown in the app + // parsed on the thread that draws. Its blocks, not its text, because + // [PeerMessageRow] draws it a block at a time. + is TranscriptItem.PeerNote -> replies.blocksOf(row.text) + else -> emptyList() + } + } if (texts.isNotEmpty()) replies.warm(texts) } }