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:
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 chunks = ConcurrentHashMap<String, List<String>>()
|
||||
|
||||
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) }
|
||||
|
||||
/** The parse of [text] -- the one made ahead, or one made now. */
|
||||
@@ -320,5 +325,6 @@ class ParsedReplies {
|
||||
parsed.clear()
|
||||
blocks.clear()
|
||||
parts.clear()
|
||||
chunks.clear()
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -44,7 +45,14 @@ fun PeerHeadRow(
|
||||
onToggle: () -> Unit,
|
||||
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) {
|
||||
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
|
||||
if (!open) {
|
||||
@@ -77,7 +85,14 @@ fun PeerBlockRow(
|
||||
onToggle: () -> Unit,
|
||||
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
|
||||
// between one block and the next. Uniform, because both of those were 6dp already.
|
||||
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
|
||||
* 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
|
||||
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason 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,
|
||||
* 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 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 {
|
||||
fun Modifier.cardPiece(
|
||||
top: Boolean,
|
||||
bottom: Boolean,
|
||||
fill: Color,
|
||||
onPress: (() -> Unit)? = null,
|
||||
): Modifier {
|
||||
val square = CornerSize(0.dp)
|
||||
val shape =
|
||||
MaterialTheme.shapes.medium.copy(
|
||||
@@ -108,15 +130,15 @@ private fun Modifier.peerSurface(top: Boolean, bottom: Boolean, onToggle: () ->
|
||||
)
|
||||
return fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(CardDefaults.cardColors().containerColor)
|
||||
.clickable(onClick = onToggle)
|
||||
.background(fill)
|
||||
.then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
|
||||
.padding(
|
||||
start = PEER_PADDING,
|
||||
end = PEER_PADDING,
|
||||
top = if (top) PEER_PADDING else 0.dp,
|
||||
bottom = if (bottom) PEER_PADDING else 0.dp,
|
||||
start = CARD_PADDING,
|
||||
end = CARD_PADDING,
|
||||
top = if (top) CARD_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)) }`. */
|
||||
private val PEER_PADDING = 12.dp
|
||||
/** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */
|
||||
private val CARD_PADDING = 12.dp
|
||||
@@ -1317,6 +1317,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
unit.last,
|
||||
onToggle = { togglePeer(unit.seq) },
|
||||
)
|
||||
is TranscriptUnit.UserChunk ->
|
||||
UserChunkRow(unit, settings, summary.id, ::openImage)
|
||||
is TranscriptUnit.Memory ->
|
||||
MemoryNote(
|
||||
unit.part,
|
||||
@@ -1860,6 +1862,38 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) =
|
||||
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.
|
||||
*
|
||||
@@ -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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
private fun UserBubble(
|
||||
|
||||
@@ -107,6 +107,28 @@ sealed class TranscriptUnit {
|
||||
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]. */
|
||||
data class Memory(
|
||||
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 (
|
||||
item is TranscriptItem.AssistantMsg && (item.settled || index != rows.lastIndex)
|
||||
) {
|
||||
@@ -192,6 +230,48 @@ fun transcriptUnits(
|
||||
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.
|
||||
*
|
||||
@@ -244,6 +324,7 @@ private val TranscriptUnit?.kind: String
|
||||
is TranscriptUnit.Block -> "reply block"
|
||||
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
|
||||
is TranscriptUnit.PeerBlock -> "peer block"
|
||||
is TranscriptUnit.UserChunk -> "user slice"
|
||||
is TranscriptUnit.Memory -> "memory note"
|
||||
is TranscriptUnit.Whole ->
|
||||
when (val row = row) {
|
||||
|
||||
Reference in new issue
Block a user