Draw a long user message in slices, through a shared card-piece surface

The occasional bump left in an otherwise smooth transcript was the last
unbounded item: a pasted log in a user bubble is one Text whose layout runs
in the frame the row scrolls into -- 93,808px on the fixture, reported from
the phone as a 112ms worst measure. Per frame it was already cheap (one
node); the cost was entirely the entry.

A message past USER_SPLIT_CHARS is now cut at line starts into slices of
roughly 2,500 characters, each its own list unit. Lines lay out
independently, so slices that own whole lines stack back into exactly the
lines the single Text drew; the threshold is also what guarantees the
bubble was at full width, which the slices must share to read as one card.
Measured on the emulator, same fixture and gestures: worst transcript
measure 57.6ms -> 12.6ms. Fill continuity across slice seams and uniform
63px line pitch verified from full-resolution screenshots; a short message
keeps the ordinary wrapping bubble.

The corner-and-padding geometry that lets one visual card be several list
items now lives once, in Modifier.cardPiece -- Bryan asked for exactly this
generalization so future row types are cheap to add. An opened peer message
and a long user message are its two users; a new sliced kind needs only a
unit type, a flatten branch, and a body wrapped in cardPiece.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5 committed 2026-09-01 17:57:41 -04:00
1 parent 7a48f8ff1f
commit 333d2de92b
4 files changed
+163 -15

No files matched your search

@@ -289,8 +289,13 @@ class ParsedReplies {
*/ */
private val parts = ConcurrentHashMap<String, List<MessagePart>>() private val parts = ConcurrentHashMap<String, List<MessagePart>>()
private val chunks = ConcurrentHashMap<String, List<String>>()
fun blocksOf(text: String): List<String> = blocks.computeIfAbsent(text) { markdownBlocks(it) } fun blocksOf(text: String): List<String> = blocks.computeIfAbsent(text) { markdownBlocks(it) }
/** How a long user message divides into slices; cached for the same reason as [blocksOf]. */
fun chunksOf(text: String): List<String> = chunks.computeIfAbsent(text) { userChunks(it) }
fun partsOf(text: String): List<MessagePart> = parts.computeIfAbsent(text) { messageParts(it) } fun partsOf(text: String): List<MessagePart> = parts.computeIfAbsent(text) { messageParts(it) }
/** The parse of [text] -- the one made ahead, or one made now. */ /** The parse of [text] -- the one made ahead, or one made now. */
@@ -320,5 +325,6 @@ class ParsedReplies {
parsed.clear() parsed.clear()
blocks.clear() blocks.clear()
parts.clear() parts.clear()
chunks.clear()
} }
} }
@@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -44,7 +45,14 @@ fun PeerHeadRow(
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier.peerSurface(top = true, bottom = !open, onToggle = onToggle)) { Column(
modifier.cardPiece(
top = true,
bottom = !open,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall) Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!open) { if (!open) {
@@ -77,7 +85,14 @@ fun PeerBlockRow(
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier.peerSurface(top = false, bottom = last, onToggle = onToggle)) { Column(
modifier.cardPiece(
top = false,
bottom = last,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
// The gap the card's own column used to provide between its heading and its prose, and // 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. // between one block and the next. Uniform, because both of those were 6dp already.
MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING)) MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING))
@@ -85,19 +100,26 @@ fun PeerBlockRow(
} }
/** /**
* One piece of a peer message's card: the fill, the corners it owns, and the room inside it. * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
* *
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which * 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 * is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
* the card can be cut up at all. Each piece paints the same container colour a * card can be cut up at all. Each piece paints the caller's container colour the way a
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message, * [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
* so the pieces abut into one continuous card. * so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
* opened peer message and a long user message -- because two copies of the corner logic is how one
* of them grows a seam.
* *
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps * 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. * the top of it, the bottom piece the bottom, and the middle pieces neither.
*/ */
@Composable @Composable
private fun Modifier.peerSurface(top: Boolean, bottom: Boolean, onToggle: () -> Unit): Modifier { fun Modifier.cardPiece(
top: Boolean,
bottom: Boolean,
fill: Color,
onPress: (() -> Unit)? = null,
): Modifier {
val square = CornerSize(0.dp) val square = CornerSize(0.dp)
val shape = val shape =
MaterialTheme.shapes.medium.copy( MaterialTheme.shapes.medium.copy(
@@ -108,15 +130,15 @@ private fun Modifier.peerSurface(top: Boolean, bottom: Boolean, onToggle: () ->
) )
return fillMaxWidth() return fillMaxWidth()
.clip(shape) .clip(shape)
.background(CardDefaults.cardColors().containerColor) .background(fill)
.clickable(onClick = onToggle) .then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
.padding( .padding(
start = PEER_PADDING, start = CARD_PADDING,
end = PEER_PADDING, end = CARD_PADDING,
top = if (top) PEER_PADDING else 0.dp, top = if (top) CARD_PADDING else 0.dp,
bottom = if (bottom) PEER_PADDING else 0.dp, bottom = if (bottom) CARD_PADDING else 0.dp,
) )
} }
/** The room inside a peer message's card, which was `Card { Column(padding(12.dp)) }`. */ /** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */
private val PEER_PADDING = 12.dp private val CARD_PADDING = 12.dp
@@ -1317,6 +1317,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
unit.last, unit.last,
onToggle = { togglePeer(unit.seq) }, onToggle = { togglePeer(unit.seq) },
) )
is TranscriptUnit.UserChunk ->
UserChunkRow(unit, settings, summary.id, ::openImage)
is TranscriptUnit.Memory -> is TranscriptUnit.Memory ->
MemoryNote( MemoryNote(
unit.part, unit.part,
@@ -1860,6 +1862,38 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
ProcessAction.Start -> startSession(settings, sessionId) ProcessAction.Start -> startSession(settings, sessionId)
} }
/**
* One slice of a long user message, on the same bubble the first slice starts.
*
* Full width, unlike the wrapping bubble: slices have to share a width to read as one card, and a
* message long enough to be sliced has lines that wrap, so its bubble was at full width anyway --
* see [USER_SPLIT_CHARS], which is what guarantees that.
*/
@Composable
private fun UserChunkRow(
unit: TranscriptUnit.UserChunk,
settings: ServerSettings,
sessionId: String,
onOpenImage: (String) -> Unit,
) {
Column(
Modifier.padding(start = 48.dp)
.cardPiece(
top = unit.first,
bottom = unit.last,
fill = MaterialTheme.colorScheme.primaryContainer,
)
) {
Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer)
// The same arrangement [UserBubble] gives them: under the words, on the last slice
// because that is the bubble's bottom.
unit.images.forEachIndexed { index, ref ->
if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp))
SessionImage(settings, sessionId, ref, onOpenImage)
}
}
}
/** /**
* A message the person holding the phone sent, in a bubble at their end of the conversation. * A message the person holding the phone sent, in a bubble at their end of the conversation.
* *
@@ -1870,6 +1904,11 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
* reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather * reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather
* than with the screen's other errors because this is where the reader pressed -- the error row is * than with the screen's other errors because this is where the reader pressed -- the error row is
* under the header, a screen away from the bubble they were looking at. * under the header, a screen away from the bubble they were looking at.
*
* A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one
* `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into.
* Everything else keeps this bubble: short messages wrap their content, and the pending one keeps
* its take-back control.
*/ */
@Composable @Composable
private fun UserBubble( private fun UserBubble(
@@ -107,6 +107,28 @@ sealed class TranscriptUnit {
get() = "p$note:$ordinal" get() = "p$note:$ordinal"
} }
/**
* One slice of a long user message; see [userChunks].
*
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the
* `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report.
*/
data class UserChunk(
override val seq: Long,
override val ordinal: Int,
val text: String,
val first: Boolean,
val last: Boolean,
/** The message's attachments, drawn under the words -- so only the last slice has any. */
val images: List<String>,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "u$seq:$ordinal"
}
/** One memory note of a settled reply; see [MemoryNote]. */ /** One memory note of a settled reply; see [MemoryNote]. */
data class Memory( data class Memory(
override val seq: Long, override val seq: Long,
@@ -165,6 +187,22 @@ fun transcriptUnits(
) )
} }
} }
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like
// the markdown splits so the scan too happens once per message, not once per fold.
val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk ->
units +=
TranscriptUnit.UserChunk(
row.startSeq,
at,
chunk,
first = at == 0,
last = at == chunks.lastIndex,
images = if (at == chunks.lastIndex) item.images else emptyList(),
gap = if (at == 0) rowGap else 0.dp,
)
}
} else if ( } else if (
item is TranscriptItem.AssistantMsg && (item.settled || index != rows.lastIndex) item is TranscriptItem.AssistantMsg && (item.settled || index != rows.lastIndex)
) { ) {
@@ -192,6 +230,48 @@ fun transcriptUnits(
return units return units
} }
/**
* Above this many characters, a user message is drawn in slices rather than as one bubble.
*
* Not zero, because a bubble's width wraps its content: slices have to fill the row to look like
* one bubble, and forcing that on a short message would visibly widen it. A message past this
* length has lines that wrap, so its bubble is at the full width already and the slices match it
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
*/
const val USER_SPLIT_CHARS = 4000
/** Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point. */
private const val USER_CHUNK_CHARS = 2500
/**
* A long user message cut at line starts into slices of roughly [USER_CHUNK_CHARS].
*
* At newlines only, never mid-line: text layout runs per line, so slices that own whole lines stack
* back into exactly the lines the single `Text` drew, and a cut inside one would reflow it. The
* newline at each cut is dropped -- the boundary between two stacked slices *is* that line break. A
* single line longer than a slice (minified JSON, a base64 blob) stays whole in its slice, so a
* slice is bounded by the longest line rather than absolutely.
*/
fun userChunks(text: String): List<String> {
val chunks = ArrayList<String>()
var start = 0
while (start < text.length) {
if (text.length - start <= USER_CHUNK_CHARS) {
chunks += text.substring(start)
break
}
var cut = text.lastIndexOf('\n', start + USER_CHUNK_CHARS)
if (cut <= start) cut = text.indexOf('\n', start + USER_CHUNK_CHARS)
if (cut < 0) {
chunks += text.substring(start)
break
}
chunks += text.substring(start, cut)
start = cut + 1
}
return chunks
}
/** /**
* Says which two units share a key, before the list dies of it. * Says which two units share a key, before the list dies of it.
* *
@@ -244,6 +324,7 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.Block -> "reply block" is TranscriptUnit.Block -> "reply block"
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading" is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
is TranscriptUnit.PeerBlock -> "peer block" is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note" is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.Whole -> is TranscriptUnit.Whole ->
when (val row = row) { when (val row = row) {