Hold the transcript still under a scroll, and parse replies before drawing

Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.

**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:

    scrolling=true atNewest=false followTail=true
    history: START last=20 total=28
    history: page of 80 events -> rows now 36
    countChanged count=36 followTail=true scrolling=true
    >>> scrollToItem(0)  SNAP

It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.

**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.

Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.

Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 14:27:26 -04:00
1 parent 1ed6e29bc6
commit fd616361eb
7 files changed
+323 -18

No files matched your search

@@ -15,6 +15,7 @@ import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -29,9 +30,9 @@ import kotlinx.coroutines.withContext
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own.
*/
@Composable
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) {
val body = MaterialTheme.typography.bodyLarge
val parsed = parsedMarkdown(text)
val parsed = parsedMarkdown(text, replies)
Markdown(
parsed,
colors =
@@ -125,12 +126,49 @@ fun MarkdownText(text: String, modifier: Modifier = Modifier) {
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
*/
@Composable
private fun parsedMarkdown(text: String): State {
private fun parsedMarkdown(text: String, replies: ParsedReplies): State {
// The text each parse came from, so the first composition's is not immediately repeated.
val parsed = remember { mutableStateOf(text to parseMarkdown(text)) }
val parsed = remember { mutableStateOf(text to replies.of(text)) }
LaunchedEffect(text) {
if (parsed.value.first == text) return@LaunchedEffect
// Not through [replies]: this is a reply still arriving, and every delta would leave
// another copy of a message that is about to be superseded.
parsed.value = text to withContext(Dispatchers.Default) { parseMarkdown(text) }
}
return parsed.value.second
}
/**
* Replies parsed before the row that draws them is composed.
*
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame they
* appeared in. That is the lag when a block loads.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, on the
* composing thread, because a row measured at nothing before it is measured at its real height
* collapses the transcript above it. The point is only that by the time the reader scrolls to a
* row, the answer is usually already made -- [warm] runs on a background thread as each page of
* history arrives, which is seconds before anybody reaches the rows it brought.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of
* itself on the way to being finished. It is dropped with the screen, and emptied by the stream
* reset that drops the rows it describes.
*/
class ParsedReplies {
private val parsed = ConcurrentHashMap<String, State>()
/** The parse of [text] -- the one made ahead, or one made now. */
fun of(text: String): State = parsed[text] ?: parseMarkdown(text)
/** Parses whatever is not held yet. Call off the composing thread; that is the whole point. */
fun warm(texts: List<String>) {
texts.forEach { text -> parsed.computeIfAbsent(text) { parseMarkdown(it) } }
}
/** Everything these described is gone; see [ParsedReplies]. */
fun clear() = parsed.clear()
}
@@ -26,24 +26,42 @@ import androidx.compose.ui.unit.dp
* seconds away, and a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(text: String, modifier: Modifier = Modifier) {
val parts = remember(text) { splitMemoryNotes(text) }
if (parts.size == 1 && parts[0] is MessagePart.Prose) {
MarkdownText(text, modifier)
fun AssistantMessage(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) {
val parts = remember(text) { partsOf(text) }
val only = parts.singleOrNull()
if (only is MessagePart.Prose) {
MarkdownText(only.text, replies, modifier)
return
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part ->
when (part) {
is MessagePart.Prose -> MarkdownText(part.text)
is MessagePart.Remembered -> MemoryNote(part)
is MessagePart.Prose -> MarkdownText(part.text, replies)
is MessagePart.Remembered -> MemoryNote(part, replies)
}
}
}
}
/**
* The pieces [AssistantMessage] draws, which is [splitMemoryNotes] with one correction.
*
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the two places that need the answer, because [markdownIn] has to name the
* same strings this draws: a string warmed under a key no row ever looks up is a miss that nothing
* reports, and the row pays the parse in the frame it appears, which is the cost being removed.
*/
private fun partsOf(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text)
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<String> = partsOf(text).map { it.text }
@Composable
private fun MemoryNote(note: MessagePart.Remembered) {
private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
// Named, not just tinted: a colour can say "this one is different", but it cannot say
@@ -54,16 +72,19 @@ private fun MemoryNote(note: MessagePart.Remembered) {
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
MarkdownText(note.text, Modifier.padding(top = 4.dp))
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
}
}
}
/** One piece of a reply: ordinary prose, or a sentence attributed to a memory file. */
sealed class MessagePart {
data class Prose(val text: String) : MessagePart()
/** The markdown this piece is drawn from. */
abstract val text: String
data class Remembered(val text: String, val files: List<String>) : MessagePart()
data class Prose(override val text: String) : MessagePart()
data class Remembered(override val text: String, val files: List<String>) : MessagePart()
}
private val MEMORY_NOTE =
@@ -32,6 +32,7 @@ fun PeerMessageRow(
item: TranscriptItem.PeerNote,
expanded: Boolean,
onToggle: (Float) -> Unit,
replies: ParsedReplies,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickableAt(onToggle)) {
@@ -50,7 +51,7 @@ fun PeerMessageRow(
)
}
}
if (expanded) MarkdownText(item.text, Modifier.padding(top = 6.dp))
if (expanded) MarkdownText(item.text, replies, Modifier.padding(top = 6.dp))
}
}
}
@@ -533,6 +533,19 @@ private fun updateTool(
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
/**
* Parses the replies among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
* [ParsedReplies].
*/
private suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
val texts = rows.filterIsInstance<TranscriptItem.AssistantMsg>().flatMap { markdownIn(it.text) }
if (texts.isEmpty()) return
withContext(Dispatchers.Default) { replies.warm(texts) }
}
@Composable
fun SessionScreen(
settings: ServerSettings,
@@ -613,6 +626,9 @@ fun SessionScreen(
var moreHistory by remember { mutableStateOf(true) }
var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) }
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because
// it describes that session's rows and nothing else.
val replies = remember(summary.id) { ParsedReplies() }
val listState = rememberLazyListState()
// Whether the newest message is on screen right now. The list is laid out from the bottom
// (see the LazyColumn below), so "newest" is index 0 and being there is being at the start of
@@ -775,6 +791,7 @@ fun SessionScreen(
try {
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
page.forEach { apply(it) }
warm(replies, items)
} catch (e: ApiException) {
// Not fatal: the stream below still replays from zero, which is
// slow but complete. Saying so beats silently showing nothing.
@@ -816,6 +833,7 @@ fun SessionScreen(
// screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does.
items = listOf()
replies.clear()
held = listOf()
oldestSeq = 0L
moreHistory = true
@@ -899,11 +917,28 @@ fun SessionScreen(
// the working indicator and a queued message are items too, and they arrive at exactly the
// same end. Sibling to the paging trigger below, which is the same count read from the other
// end for the same reason.
//
// Never while a scroll is running, and that is a rule of its own rather than a refinement of
// the condition beside it: a list must not be moved out from under a hand that is moving it.
// The two disagree because [followTail] is deliberately a *remembered* answer, rewritten only
// when a scroll settles -- so for the whole of a fling it still reports the newest end, where
// the reader was when they threw it. A page of history landing during that fling is a change
// in the count, and the correction meant for an insertion at the newest end then fired for
// one at the oldest: the reader was thrown back to the bottom mid-flight. It could happen
// only once, which is what made it look arbitrary rather than mechanical -- the snap settles
// the scroll at the newest end, so the next fling gets far enough to settle away from it, and
// from then on [followTail] is false and nothing fires. Skipping the correction outright is
// right rather than merely safe: the count can only have grown at the newest end while the
// reader is already there, because [record] holds everything else until they come back.
LaunchedEffect(listState) {
snapshotFlow {
Pair(listState.layoutInfo.totalItemsCount, listState.layoutInfo.viewportSize.height)
}
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
.collect { (count, _) ->
if (followTail && !listState.isScrollInProgress && count > 0) {
listState.scrollToItem(0)
}
}
}
// Reaching the far end of what is loaded -- the oldest item, which in
// this layout is the last index -- fetches the page before it.
@@ -959,7 +994,14 @@ fun SessionScreen(
earlier = foldEvent(earlier, entry)
}
}
items = joinPages(earlier, items)
val joined = joinPages(earlier, items)
// After the join rather than on the page alone: a boundary that fell
// through a reply leaves `joinPages` holding a message made of both
// halves, and that text has existed for no time at all. Warming the page
// by itself warmed the two halves and missed the one thing drawn --
// which showed up as a single 22ms parse surviving every page.
warm(replies, joined)
items = joined
have = groupToolRuns(items).size
}
} catch (_: ApiException) {
@@ -1260,7 +1302,8 @@ fun SessionScreen(
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.AssistantMsg ->
AssistantMessage(item.text, replies)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
@@ -1319,6 +1362,7 @@ fun SessionScreen(
PeerMessageRow(
item = item,
expanded = item.seq in expandedNotes,
replies = replies,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
expandedNotes =