Draw a long reply to a couple of screens, with the rest a press away

Iris approved the cap and set the one exception: never the latest
response. That one is being read as it arrives, often still arriving, and
putting "show the rest" under the turn somebody is waiting for hides the
answer they are waiting for. Everything behind it is history, which is
what this is for.

The limit is 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 throws the overflow away, so the line-breaking has
already happened -- while cutting the string before it is parsed stops
the work being done at all, the parse included. Four thousand characters
is about two screenfuls, with a thousand of slack so that a reply barely
over the limit is not given a control worth nothing to anybody.

The cut is 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 was dropped. A fence left open is closed,
which is the one case a line boundary does not cover -- 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 cannot tell that from the reply
genuinely having been code. Verified against a reply whose cut lands
inside a 400-line Rust block: the block renders as a block, closes, and
the control sits below it in ordinary text.

`markdownIn` now names both forms, because which one a row draws is not
decided there -- so pressing the control is a cache hit rather than a
50ms parse on the frame it happens in, and neither form is a key that no
row ever looks up.

Measured on the emulator, now that it renders on the GPU and its frame
numbers mean something -- two flings over replies of the same size in the
same session, one capped and one not:

                       uncapped 62,310    capped 54,990 (4,000 drawn)
  janky (legacy)            58.20%              11.33%
  slow UI-thread frames          5                   1
  p50                         23ms                16ms
  p90                         30ms                24ms

The modern "Janky frames" figure is 4.04% against 3.59% -- near identical,
because both stay inside the compositor's deadline on this emulator. The
win is UI-thread work, which is what was aimed at, and the legacy metric
is the one that counts it.

Expanded is remembered by the screen, beside the other expansion sets, so
scrolling away and back does not shut something deliberately opened.

Known and not fixed: the floating jump-to-latest control is centred at the
bottom of the list and overlaps this one's label whenever a capped reply's
foot lands in that band. It is the overlay's pre-existing disregard for
content -- long replies have always had text under it -- but a control
there is worse than prose, and it is now common rather than incidental.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 00:19:20 -04:00
1 parent 0dc248b9e4
commit 592038aa54
3 files changed
+154 -4

No files matched your search

@@ -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"
}
}