diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LongReply.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LongReply.kt new file mode 100644 index 0000000..343b0aa --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/LongReply.kt @@ -0,0 +1,101 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * How much of a reply is drawn before the reader is offered the rest. + * + * A limit on the *source*, not on the height, and that is the whole point. Clipping a laid-out row + * to a height saves nothing: Compose measures the text and then throws the overflow away, so the + * line-breaking has already happened. Cutting the string before it is parsed is what stops the work + * from being done -- and it stops the parse too, which the height version could never reach. + * + * Four thousand characters is about two screenfuls of body text on a phone. Two rather than one so + * that a reply somewhat over the limit is not cut nearly in half, and so a reader who does not + * press anything still gets more than they can see at once. + * + * The measurement that made this worth having, from the ai-app-2 session on 2026-08-30: one message + * in a real transcript is over 14,000px tall -- seven screens -- and a single frame spent 59.6ms in + * `measureAndLayout` when it entered the viewport. That cost is proportional to the whole row + * however little of it is on screen, and it is paid again every time the row comes back. + */ +private const val REPLY_CAP_CHARS = 4000 + +/** + * How far past the cap a reply has to be before it is worth cutting. + * + * Without this a message of 4,001 characters loses one character and gains a button, which is worth + * nothing to anybody and costs a control that has to be read and decided about. The row that needs + * this treatment is several times the limit, not just over it. + */ +private const val REPLY_CAP_SLACK = 1000 + +/** + * The opening of [text] if it is long enough to be worth cutting, or null if it should be drawn + * whole. + * + * Cut at a line ending, because a markdown source cut mid-line is a different document: half a + * heading marker, a list item with no bullet, a link whose closing bracket is in the part that was + * dropped. A whole number of lines is the coarsest cut that cannot invent syntax. + * + * A fence left open by the cut is closed, which is the one case a line boundary does not save. An + * unterminated ``` swallows the rest of the reply into a code block, so the truncation would change + * how the part still on screen is *drawn* rather than only how much of it there is -- and a reader + * has no way to tell that from the reply genuinely having been code. + */ +fun shortenedReply(text: String, limit: Int = REPLY_CAP_CHARS): String? { + if (text.length <= limit + REPLY_CAP_SLACK) return null + val cut = text.lastIndexOf('\n', limit).let { if (it <= 0) limit else it } + val head = text.substring(0, cut) + // Fences are counted rather than matched: an opening and a closing one are the same token, so + // an odd number of them is an opening that never closed. + return if (head.split("\n").count { it.trimStart().startsWith("```") } % 2 == 1) "$head\n```" + else head +} + +/** + * A reply drawn to [REPLY_CAP_CHARS], with the rest a press away. + * + * The control says how much is behind it rather than only "Show more", because the two answers a + * reader wants are different sizes of decision: another paragraph is worth opening while standing + * in a scroll, and another twenty screens is worth knowing about first. + * + * Expanded is remembered by the screen rather than by this row, so scrolling away and back does not + * shut something the reader deliberately opened -- see SessionScreen's other expansion sets. + */ +@Composable +fun CappedReply( + full: String, + shortened: String, + replies: ParsedReplies, + onShowMore: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth()) { + AssistantMessage(shortened, replies) + TextButton(onClick = onShowMore) { + Text( + "Show the rest (${remaining(full.length - shortened.length)})", + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +/** What is left, in the units a reader thinks in rather than in characters. */ +private fun remaining(chars: Int): String { + // Against the same figure the cap is written in, so the two cannot drift: a "screenful" here is + // whatever [REPLY_CAP_CHARS] is two of. + val screens = chars.toDouble() / (REPLY_CAP_CHARS / 2) + return when { + screens < 1.5 -> "about another screen" + screens < 20 -> "about ${Math.round(screens)} more screens" + else -> "more than 20 screens" + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt index 70ac150..51f6057 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -57,8 +57,18 @@ private fun partsOf(text: String): List { return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts } -/** Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready. */ -fun markdownIn(text: String): List = partsOf(text).map { it.text } +/** + * Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready. + * + * Both forms of a long one, because which gets drawn is not decided here: the newest reply is drawn + * whole and every other long one is drawn cut (see [shortenedReply]), and the reader can ask for + * the rest of any of them. Warming only one of the two would leave the other parsing on the thread + * that draws, in the frame the row appears -- and a string warmed under a key no row ever looks up + * is a miss that nothing reports. The extra parse is off the composing thread, which is the only + * place it would have cost anything. + */ +fun markdownIn(text: String): List = + partsOf(text).flatMap { part -> listOfNotNull(part.text, shortenedReply(part.text)) } @Composable private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { 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 3f88bf9..f1703ac 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -642,6 +642,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // by default, which is the rule for anything new in this transcript: a screen that opens // everything it can is one nobody can scan. var expandedNotes by remember { mutableStateOf(setOf()) } + // Long replies the reader has asked to see the rest of, by the seq of the row. Held here + // rather than in the row so that scrolling away and back does not shut something they + // deliberately opened -- the same reason the sets above it are here. + var expandedReplies by remember { mutableStateOf(setOf()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } // What this session is set to now, seeded from the row that opened it and @@ -722,6 +726,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // What is actually drawn: the transcript with runs of adjacent tool // calls folded into one row each. val rows = remember(items) { groupToolRuns(items) } + // The reply drawn whole however long it is; see the transcript list below. Recomputed with + // `items` rather than tracked as it arrives, because "newest" moves: a reply that was the + // last one becomes history the moment the next turn starts, and a row that kept its + // exemption after that would be the one enormous row this exists to bound. + val newestReply = + remember(items) { items.filterIsInstance().lastOrNull()?.seq } /** * Everything the transcript list draws, from one event. @@ -1580,8 +1590,37 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () text = item.text, images = item.images, ) - is TranscriptItem.AssistantMsg -> - AssistantMessage(item.text, replies) + is TranscriptItem.AssistantMsg -> { + // The newest reply is never cut. It is the one being read + // as it arrives -- often still arriving -- and putting a + // "show the rest" under a turn somebody is waiting for + // hides the answer they are waiting for. Every reply + // behind it is history, and history is what this is for. + val shortened = + if ( + item.seq == newestReply || + item.seq in expandedReplies + ) + null + else remember(item.text) { shortenedReply(item.text) } + if (shortened == null) { + AssistantMessage(item.text, replies) + } else { + CappedReply( + full = item.text, + shortened = shortened, + replies = replies, + onShowMore = { + // Anchored like every other row that changes + // height, so the edge the reader touched + // stays where it is. + toggleAnchored(row.key, bounds, bounds.top) { + expandedReplies = expandedReplies + item.seq + } + }, + ) + } + } is TranscriptItem.ToolRun -> ToolCard( tool = item,