Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt
T
iris afbc2ad132 Cap what the transcript draws, and let a LazySpan clip itself
Four things Iris asked for on 2026-09-08.

**A LazySpan no longer cares about masks.** It asserted that something
around it had called `.masked()` and refused to draw otherwise, which is
why a plain full-screen list -- the benchmark, any simple app -- panicked.
It cared only because it draws a row straddling an edge in full and relied
on somebody else to cut off the overhang; it clips itself to the box it
was offered now. Strictly stronger than the assert, which a mask *larger*
than the list's box satisfied while letting the overhang through anyway --
the fault it was written for. The transcript's `.masked()` wrapper goes
with it, and `Painter::is_masked` with that.

**Everything on the transcript screen is capped.** One rule in one place,
`client_core::text_cap`, mirrored as `TextCap.kt` with the same numbers so
a bench comparing the apps compares renderers rather than policies:

    a tool call's input    80 lines or 4 KiB   -> "Show all N lines"
    a tool call's output   80 lines or 4 KiB   -> (already was, in iris)
    a message             200 lines or 16 KiB  -> "Show all N lines"

The input is what the edit-card report needed: an Edit's old_string and
new_string arrive whole and are routinely the biggest text on screen.
Messages are capped in both apps, user and agent alike.

Three rules that took a screenshot to get right. A message is cut on a
block boundary, never mid-block -- cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap --
except a message that is one enormous block, which is truncated, since
dropping it would leave the row blank. A reply still streaming is never
capped. And the input's two blocks share one "Show all", while input and
output have their own.

**Compose stops wrapping raw text**, per Iris's call: a tool's leftover
input fields and its output pan sideways like the command already did.

`on_tap` and hold-the-edge move to `transcript-ui/src/tap.rs`, since a
message's "Show all" needs exactly what a tool card's tap already had.
2026-09-08 22:59:18 -04:00

479 lines
21 KiB
Kotlin

package com.example.aiapp
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* One item of the transcript list: a whole row, or one block of a settled reply.
*
* The unit of laziness is deliberately smaller than a message. A lazy list pays to compose an item
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
*/
@Immutable
sealed class TranscriptUnit {
/** The list identity; must survive pages landing at either end. See [TranscriptRow.key]. */
abstract val key: Any
/** Where this unit's row starts in the transcript -- the anchor identity, never the key. */
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end. What a saved scroll
* position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
/** The gap drawn above this unit -- between rows, or between blocks of one reply. */
abstract val gap: Dp
/** A row drawn as itself: a bubble, a tool card, a group -- or the reply still arriving. */
data class Whole(val row: TranscriptRow, override val gap: Dp) : TranscriptUnit() {
override val key: Any
get() = row.key
override val seq: Long
get() = row.startSeq
override val ordinal: Int
get() = 0
}
/** 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
get() = "b$seq:$ordinal"
}
/**
* The heading of a message from another agent: who sent it, and the control that opens it.
*
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* 0.39ms to 3.15ms.
*
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns.
*/
data class PeerHead(
override val seq: Long,
val item: TranscriptItem.PeerNote,
val open: Boolean,
override val gap: Dp,
) : TranscriptUnit() {
/**
* The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items.
*/
override val key: Any
get() = item.key
override val ordinal: Int
get() = 0
}
/**
* 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,
) : TranscriptUnit() {
override val key: Any
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.
*/
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 attachments: List<String>,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "u$seq:$ordinal"
}
/**
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
*
* Its own unit rather than something inside the row above it, because the row above it is a
* *bounded* item now and this is what says so -- and because a control that lives inside the
* thing it reveals moves the moment it is pressed.
*/
data class ShowAll(
override val seq: Long,
override val ordinal: Int,
/** The row this belongs to; what goes into the set of rows shown whole. */
val row: Any,
/** The line count of the whole message, which is what the offer says. */
val lines: Int,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "s$row"
}
/** One memory note of a settled reply; see [MemoryNote]. */
data class Memory(
override val seq: Long,
override val ordinal: Int,
val part: MessagePart.Remembered,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "m$seq:$ordinal"
}
}
/**
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
*
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
* untouched by a reader opening or closing one.
*
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
* hundred lines while the model was plainly still writing would read as the stream having died.
* `iris`'s `row::build_row` states the same rule for the same reason.
*/
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
if (row.key in shownWhole) return row to null
val cut =
when {
item is TranscriptItem.UserMsg ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
item is TranscriptItem.AssistantMsg && item.settled ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
else -> null
} ?: return row to null
return cut.second to cut.first.lines
}
/**
* 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 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. A shut one
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. 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.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>,
replies: ParsedReplies,
openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> {
val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, whole ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val (row, hidden) = capRow(whole, shownWhole)
val rowStart = units.size
val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes
units += TranscriptUnit.PeerHead(row.startSeq, item, open, rowGap)
// 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 pieces = replies.piecesOf(item.text)
var previous: Piece? = null
pieces.forEachIndexed { at, piece ->
units +=
TranscriptUnit.PeerBlock(
row.startSeq,
at + 1,
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) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// markdown splits so the scan happens once per message rather than 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,
attachments = if (at == chunks.lastIndex) item.attachments else emptyList(),
gap = if (at == 0) rowGap else 0.dp,
)
}
} else if (
item is TranscriptItem.AssistantMsg &&
splitWanted(item, index, rows.lastIndex) &&
replies.splitReady(item.text)
) {
var ordinal = 0
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
replies.partsOf(item.text).forEach { part ->
when (part) {
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(BLOCK_SPACING))
ordinal++
}
}
}
} else {
units += TranscriptUnit.Whole(row, rowGap)
}
if (hidden != null) {
units +=
TranscriptUnit.ShowAll(
row.startSeq,
units.size - rowStart,
row.key,
hidden,
BLOCK_SPACING,
)
}
}
units.reverse()
reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started)
return units
}
/**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
*
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* answered by different things: this one by the fold, the other by whether [warm] has run.
*/
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex
/**
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
*
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
fun unwarmedReplies(
rows: List<TranscriptRow>,
replies: ParsedReplies,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
// The *capped* row's text, since that is what the flatten will draw and so what has to be
// ready: a capped row draws its head, which is a different string from the message and so a
// different cache entry.
val row = capRow(whole, shownWhole).first
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
/**
* 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.
*
* About one viewport of wrapped text: a slice is composed whole in the frame it scrolls into, so
* its size is a frame-budget decision, and one screenful keeps that to a few milliseconds on the
* phone. Smaller buys nothing -- the seams are free -- but the units multiply.
*/
private const val USER_CHUNK_CHARS = 1000
/**
* 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.
*
* A duplicate key is fatal -- `LazyColumn` throws, and the app goes down in the middle of somebody
* reading a conversation -- and all the framework's message carries is the key. When that key is a
* seq it names neither row, and there is no way back from it to how the two came to share one: it
* took an afternoon and a fixture that could reproduce it. Two lines here answered it immediately,
* naming both rows and the field they had in common ([TranscriptItem.PeerNote.arrived]).
*
* Always on, for the same reason [DebugStats] is: an instrument that is only in the build nobody is
* holding when it breaks is not an instrument. It costs one map over the units that were just
* built, beside a loop that already allocates one entry per unit.
*/
private fun reportDuplicateKeys(units: List<TranscriptUnit>) {
val seen = HashMap<Any, TranscriptUnit>()
units.forEach { unit ->
val had = seen.put(unit.key, unit)
if (had != null) {
android.util.Log.w("ai-app", "duplicate unit key ${unit.key}: $had AND $unit")
}
}
}
/**
* What is on screen right now, a unit at a time: what each one is and how tall it is.
*
* For the render report, and it is the line every "it is slow here" report has needed. The
* framework's own per-frame cost grows with how many nodes are *alive* rather than how many are on
* screen, so a screen holding one enormous item is slow in a way that no counter of ours
* distinguishes from a screen holding twenty ordinary ones -- and "2 units visible" says one of
* them is enormous without saying which. This says which.
*
* [first] is the index the list gave the first *unit*: the list also holds the waiting-messages
* slot at index zero and the history spinner past the end, and both are named here rather than
* silently reported as whichever unit is nearest.
*/
fun visibleUnits(units: List<TranscriptUnit>, visible: List<LazyListItemInfo>, first: Int): String =
if (visible.isEmpty()) " nothing on screen"
else
" on screen: " +
visible.joinToString(", ") { info ->
"${units.getOrNull(info.index - first).kind} ${info.size}px"
}
/** What a unit is, in a word, for [visibleUnits]. Null is one of the list's own non-unit items. */
private val TranscriptUnit?.kind: String
get() =
when (this) {
null -> "the list's own"
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"
is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ShowAll -> "show all"
is TranscriptUnit.Whole ->
when (val row = row) {
is TranscriptRow.Tools -> "tool group"
// The class name rather than a word per kind: this is a diagnostic, and a
// `when` here would be one more place that has to gain a case whenever the
// transcript does -- silently naming a new row after an old one until somebody
// noticed.
is TranscriptRow.Single -> row.item::class.simpleName.orEmpty()
}
}
/**
* Where the unit named by a saved position sits in [units], or null if its row is not loaded.
*
* The row is found by [seq] and the unit within it by [ordinal], settling for the nearest older
* unit when the exact one is gone -- a reply regrouped by a page boundary can split into a
* different number of blocks than it had when the position was saved, and "a little above where
* they stopped" loses less than the newest end does.
*/
fun unitIndexFor(units: List<TranscriptUnit>, seq: Long, ordinal: Int): Int? {
var best: Int? = null
var bestOrdinal = -1
units.forEachIndexed { index, unit ->
if (unit.seq == seq && unit.ordinal <= ordinal && unit.ordinal > bestOrdinal) {
best = index
bestOrdinal = unit.ordinal
}
}
return best ?: units.indexOfFirst { it.seq == seq }.takeIf { it >= 0 }
}