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) } }