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 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<Long>()
private val waited = 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 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. */