diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 3efe3a4..4268fb6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -289,8 +289,13 @@ class ParsedReplies { */ private val parts = ConcurrentHashMap>() + private val chunks = ConcurrentHashMap>() + fun blocksOf(text: String): List = 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 = chunks.computeIfAbsent(text) { userChunks(it) } + fun partsOf(text: String): List = 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() } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index b7d0567..5214a5f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 0673bbc..7551fb0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt index f922032..35187ba 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt @@ -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, + 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 { + val chunks = ArrayList() + 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) {