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
@@ -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