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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-01 15:20:24 -04:00
1 parent 6d12388063
commit 80aaf286c2
4 files changed
+56 -33

No files matched your search

@@ -10,7 +10,6 @@ import android.view.FrameMetrics
import android.view.Window import android.view.Window
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext 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, * 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 * 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. * 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<Long>() private val total = ArrayList<Long>()
private val waited = ArrayList<Long>() private val waited = ArrayList<Long>()
private val input = ArrayList<Long>() private val input = ArrayList<Long>()
@@ -108,28 +114,28 @@ class FrameStats {
} }
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole) 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 * 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. * documentation is explicit that doing that on the main thread taxes the very thing being measured.
*/ */
@Composable @Composable
fun rememberFrameStats(): FrameStats { fun recordFrames() {
val stats = remember { FrameStats() }
val window = LocalContext.current.activity()?.window val window = LocalContext.current.activity()?.window
DisposableEffect(window) { DisposableEffect(window) {
if (window == null) return@DisposableEffect onDispose {} if (window == null) return@DisposableEffect onDispose {}
val thread = HandlerThread("frame-stats").apply { start() } val thread = HandlerThread("frame-stats").apply { start() }
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ -> val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
stats.add(metrics) FrameStats.add(metrics)
} }
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper)) window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
onDispose { onDispose {
@@ -137,7 +143,6 @@ fun rememberFrameStats(): FrameStats {
thread.quitSafely() thread.quitSafely()
} }
} }
return stats
} }
/** The activity behind a composable's context, which is what owns the window. */ /** The activity behind a composable's context, which is what owns the window. */
@@ -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 * 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 * 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. * 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 @Composable
fun PeerMessageRow( 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))
} }
} }
} }
@@ -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 // 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. // the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup) val usage = rememberSessionUsage(settings, summary.setup)
val frames = rememberFrameStats() recordFrames()
var usageOpen by remember { mutableStateOf(false) } var usageOpen by remember { mutableStateOf(false) }
var settingsOpen 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" + " ${expandedTools.size} tool calls and" +
" ${expandedGroups.size} groups open", " ${expandedGroups.size} groups open",
), ),
frames = frames.lines(context.refreshHz()), frames = FrameStats.lines(context.refreshHz()),
accounting = accounting =
frames.drawPhase().let { (nanos, count) -> FrameStats.drawPhase().let { (nanos, count) ->
drawAccounting(nanos, count) drawAccounting(nanos, count)
}, },
crash = lastCrash(context), 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 // Emptied by the copy, so pressing it twice measures two separate
// stretches // stretches
// of scrolling rather than one and then the same one again. // of scrolling rather than one and then the same one again.
frames.reset() FrameStats.reset()
DebugStats.reset() DebugStats.reset()
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT) Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
.show() .show()
@@ -466,32 +466,46 @@ private fun updateTool(
private val parsingThreads = Dispatchers.Default.limitedParallelism(2) 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 * 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 * whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies]. * [ParsedReplies].
* *
* What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory * 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 * note whole, a peer message split the same way prose is -- because a string warmed under a key no
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same * row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten
* [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] caches the flatten does, so a message is * this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf]
* scanned once however many pages hand it back through here, while the whole loaded transcript * caches the flatten does, so a message is scanned once however many pages hand it back through
* crosses this on every page. * 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<TranscriptItem>) { suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) { withContext(parsingThreads) {
val texts = val texts = rows.flatMap { row ->
rows when (row) {
.filterIsInstance<TranscriptItem.AssistantMsg>() is TranscriptItem.AssistantMsg ->
.flatMap { replies.partsOf(it.text) } replies.partsOf(row.text).flatMap { part ->
.flatMap { part -> when (part) {
when (part) { is MessagePart.Prose -> replies.blocksOf(part.text)
is MessagePart.Prose -> replies.blocksOf(part.text) // Drawn as one MarkdownText, so its whole text is the key
// Drawn as one MarkdownText, so its whole text is the key looked up. // looked up.
is MessagePart.Remembered -> listOf(part.text) 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) if (texts.isNotEmpty()) replies.warm(texts)
} }
} }