Draw an opened peer message as list units, and give a peer note its own key

A peer message opened was still one item of the transcript list, so every
block of it was composed, measured, placed and kept *alive* while any part
of it was on screen -- and the framework's own per-frame cost grows with how
many nodes are alive rather than how many are drawn. Measured on the
emulator, scrolling the same stretch with a 43KB message shut, opened, and
opened after this change:

                              shut     opened    opened, split
  draw phase per frame       0.81ms    3.85ms    1.22ms
  of that, the framework     0.39ms    3.15ms    0.42ms
  frame total, median       16.9ms    24.3ms    21.1ms

So an opened message now costs about what a shut one does. That is the
shape the report from the phone had -- 11.80ms of draw phase with 79% of it
outside anything this app times -- which no counter here could attribute,
because "2 units visible" says one of them is enormous without saying which.
It says which now: the transcript section of the report names every visible
unit and its height, which is what found this.

The card is cut up rather than given up. A filled Material card is elevation
zero (`FilledCardTokens.ContainerElevation` is `Level0`), so there is no
shadow for a seam to show through: each piece paints the same fill, rounds
only the corners at the ends of the message, and keeps the 12dp inset the
card's own column had. Opening and shutting still hold the edge the reader
pressed, and now without a correction -- the list is keyed, so it holds the
item it is anchored on wherever the new ones land.

The crash this turned up is the more serious half. `placePeerNote` gives a
note the seq of the turn it started so it sorts above the reply it caused,
and argued the seq was free because it belongs to a status change and a
status draws no row. True, and about the wrong collision: two messages that
arrive during one turn are stamped with the same turn, so they became two
rows with one key and `LazyColumn` threw -- the app dying in the middle of
somebody reading. Two agents writing to a session mid-turn is an ordinary
afternoon. A note now keeps its own arrival seq as its identity while `seq`
stays the position it sorts at, and which value a row is keyed by moved onto
`TranscriptItem` itself, which also removes the `as? ToolRun` branch that
was doing the same job in `TranscriptRow.Single`.

`transcriptUnits` now says which two units collided if it ever happens
again. All the framework's message carries is the key, and when that key is
a seq it names neither row; two lines here answered in one run what had
taken an afternoon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-01 16:12:24 -04:00
1 parent 80aaf286c2
commit 917eb9a7b3
6 files changed
+321 -51

No files matched your search

@@ -1,5 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -7,12 +8,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -28,35 +31,92 @@ import androidx.compose.ui.unit.dp
* 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].
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
* bought; what matters here is that the pieces have to add up to the card that was there before, so
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
* out at each piece.
*/
@Composable
fun PeerMessageRow(
fun PeerHeadRow(
item: TranscriptItem.PeerNote,
expanded: Boolean,
open: Boolean,
onToggle: () -> Unit,
replies: ParsedReplies,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!expanded) {
Spacer(Modifier.width(8.dp))
Text(
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a message is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
Column(modifier.peerSurface(top = true, bottom = !open, onToggle = onToggle)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!open) {
Spacer(Modifier.width(8.dp))
Text(
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a message is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
if (expanded) BlockedMarkdown(item.text, replies, Modifier.padding(top = 6.dp))
}
}
}
/**
* One block of an opened peer message, on the same card the heading started.
*
* Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control
* before it was several items, and which piece the finger lands on is not something the reader
* chose.
*/
@Composable
fun PeerBlockRow(
text: String,
replies: ParsedReplies,
last: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.peerSurface(top = false, bottom = last, onToggle = onToggle)) {
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next. Uniform, because both of those were 6dp already.
MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING))
}
}
/**
* One piece of a peer message's card: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason
* the card can be cut up at all. Each piece paints the same container colour a
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
* so the pieces abut into one continuous card.
*
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps
* the top of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
private fun Modifier.peerSurface(top: Boolean, bottom: Boolean, onToggle: () -> Unit): Modifier {
val square = CornerSize(0.dp)
val shape =
MaterialTheme.shapes.medium.copy(
topStart = if (top) MaterialTheme.shapes.medium.topStart else square,
topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square,
bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square,
bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square,
)
return fillMaxWidth()
.clip(shape)
.background(CardDefaults.cardColors().containerColor)
.clickable(onClick = onToggle)
.padding(
start = PEER_PADDING,
end = PEER_PADDING,
top = if (top) PEER_PADDING else 0.dp,
bottom = if (bottom) PEER_PADDING else 0.dp,
)
}
/** The room inside a peer message's card, which was `Card { Column(padding(12.dp)) }`. */
private val PEER_PADDING = 12.dp