Draw replies as pieces of one parse, and lists an item at a time

A settled reply used to be cut into block *strings*, each parsed on its own
and each a unit of the lazy list; the live reply split the same way with a
whole-message parse per delta on top. Now a message is parsed once, and a
Piece addresses a top-level block of that tree -- or one item of a
top-level list, which was the one block still unbounded: a list of forty
sources was one item composed whole in the frame it scrolled into. Units,
the live reply's column and peer messages all draw from the same parse,
so warm parses each message once instead of once per block, a delta costs
one background parse instead of two, and a reference definition at the
foot of a message resolves again because nothing is parsed apart from it.

The renderer keeps parsing and providing its environment; MarkdownRoot
wraps that around a piece, and a whole block still goes through its
dispatch with our component table. List items are drawn here, with the
renderer's own paddings so a split list looks like an unsplit one, and
lists inside quotes come to the same code through the table -- the marker
is drawn in one place, which is what a styled bullet would need later.

Found on the way: a heading's words are a child of the heading node, and
the inline builder draws nothing for a node type it does not know, so the
span-link path had been drawing headings empty. LinkedHeading hands it the
content child.

Lint: profileable's shell attribute scoped to API 29 where it exists, and
recordFrames renamed to the composable convention. What remains is the
AGP 9.4.0 notice.

Verified on the emulator against a fixture of every block kind (headings,
nested and ordered lists with a start number, task items, a quote holding
a list, a fence, a rule, a table with a linked cell, a setext heading), a
forty-item list which the render report now shows as per-item units, a
reply streamed live (34 deltas: 34 background reparses, one warm at
settle, no crash), and the older link fixture. ktfmt, build and lint run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-02 19:34:41 -04:00
1 parent a9ab9b3e5a
commit 48bb7de304
12 files changed
+457 -221

No files matched your search

@@ -49,11 +49,12 @@ sealed class TranscriptUnit {
get() = 0
}
/** One markdown block of a settled reply. */
/** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
data class Block(
override val seq: Long,
override val ordinal: Int,
val text: String,
val piece: Piece,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
@@ -93,12 +94,18 @@ sealed class TranscriptUnit {
get() = 0
}
/** One markdown block of an opened peer message; [last] is the piece that closes the card. */
/**
* One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
* always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
* inside the piece where the card's fill covers it.
*/
data class PeerBlock(
override val seq: Long,
override val ordinal: Int,
val text: String,
val piece: Piece,
val last: Boolean,
val spacing: Dp,
override val gap: Dp,
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
val note: Any,
@@ -145,19 +152,19 @@ sealed class TranscriptUnit {
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
* message is only ever split once), and so is an *opened* peer message -- [openNotes] is which ones
* those are, which is why the flatten needs it. A shut one is a single heading and cannot be worth
* splitting. The reply still arriving -- the newest row, until the status event that ends its turn
* marks it [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta,
* and splitting it here would parse the whole message per delta on whichever thread is composing.
* Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is
* only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are,
* which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting.
* The reply still arriving -- the newest row, until the status event that ends its turn marks it
* [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and
* splitting it here would parse the whole message per delta on whichever thread is composing.
* [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the
* live message a layer per block. Once settled it splits like every other reply, which is what
* live message a layer per piece. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
* seen, and a miss -- the one message that just finished streaming -- costs its split exactly once.
* path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
* seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
*/
fun transcriptUnits(
rows: List<TranscriptRow>,
@@ -175,17 +182,21 @@ fun transcriptUnits(
// No gap between the pieces: they are one card, and a card with a stripe through it is
// what any spacing here would draw.
if (open) {
val blocks = replies.blocksOf(item.text)
blocks.forEachIndexed { at, block ->
val pieces = replies.piecesOf(item.text)
var previous: Piece? = null
pieces.forEachIndexed { at, piece ->
units +=
TranscriptUnit.PeerBlock(
row.startSeq,
at + 1,
block,
last = at == blocks.lastIndex,
item.text,
piece,
last = at == pieces.lastIndex,
spacing = gapBefore(previous, piece),
gap = 0.dp,
note = item.key,
)
previous = piece
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
@@ -210,16 +221,27 @@ fun transcriptUnits(
replies.splitReady(item.text)
) {
var ordinal = 0
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
replies.partsOf(item.text).forEach { part ->
when (part) {
is MessagePart.Prose ->
replies.blocksOf(part.text).forEach { block ->
units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap())
is MessagePart.Prose -> {
var previous: Piece? = null
replies.piecesOf(part.text).forEach { piece ->
units +=
TranscriptUnit.Block(
row.startSeq,
ordinal,
part.text,
piece,
gap(gapBefore(previous, piece)),
)
ordinal++
previous = piece
}
}
is MessagePart.Remembered -> {
units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap())
units +=
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
ordinal++
}
}
@@ -358,7 +380,8 @@ private val TranscriptUnit?.kind: String
get() =
when (this) {
null -> "the list's own"
is TranscriptUnit.Block -> "reply block"
is TranscriptUnit.Block ->
if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"