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>
139 lines
5.8 KiB
Kotlin
139 lines
5.8 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.shape.CornerSize
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
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
|
|
|
|
/**
|
|
* A message another agent sent this session, closed until somebody asks.
|
|
*
|
|
* Closed by default, like a tool call and for the same reason: these are long, there can be several
|
|
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
|
|
* sent it. The first line comes with the heading because a name alone does not say which message
|
|
* this was.
|
|
*
|
|
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
|
|
* transcript that puts it in their voice is making a claim about who asked for the work that
|
|
* follows -- which is exactly the question a peer message is usually the answer to.
|
|
*
|
|
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
|
|
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that
|
|
* bought; what matters here is that the pieces have to add up to the card that was there before, so
|
|
* the fill, the corner radius and the padding all live in [peerSurface] rather than being written
|
|
* out at each piece.
|
|
*/
|
|
@Composable
|
|
fun PeerHeadRow(
|
|
item: TranscriptItem.PeerNote,
|
|
open: Boolean,
|
|
onToggle: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
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) {
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
// The head, not the tail: a message is identified by how it opens.
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One block of an opened peer message, on the same card the heading started.
|
|
*
|
|
* Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control
|
|
* before it was several items, and which piece the finger lands on is not something the reader
|
|
* chose.
|
|
*/
|
|
@Composable
|
|
fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
|
|
Column(
|
|
Modifier.cardPiece(
|
|
top = false,
|
|
bottom = unit.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 -- inside the piece, so the card's fill runs through it.
|
|
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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. 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
|
|
fun Modifier.cardPiece(
|
|
top: Boolean,
|
|
bottom: Boolean,
|
|
fill: Color,
|
|
onPress: (() -> Unit)? = null,
|
|
): Modifier {
|
|
val square = CornerSize(0.dp)
|
|
val shape =
|
|
MaterialTheme.shapes.medium.copy(
|
|
topStart = if (top) MaterialTheme.shapes.medium.topStart else square,
|
|
topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square,
|
|
bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square,
|
|
bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square,
|
|
)
|
|
return fillMaxWidth()
|
|
.clip(shape)
|
|
.background(fill)
|
|
.then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
|
|
.padding(
|
|
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 sliced card, which was `Card { Column(padding(12.dp)) }`. */
|
|
private val CARD_PADDING = 12.dp
|