Replace the hand-built transcript window with a reversed lazy list of blocks

The plain Column held every loaded row and reimplemented what a lazy
list is -- windowing (retained ranges, stand-in spacers), height
bookkeeping (a heights map and prefix-summed tops), scroll anchoring,
and restore -- and every constraint kept intersecting that surface
somewhere new: the retained window was a fresh way to flicker, the
spacers hit the Constraints height limit, the IME re-measured the
world, and the restore needed pending anchors threaded through layout.

The reason a lazy list was abandoned no longer holds. It was dropped
when an item was a whole message, and a message can be twenty-five
screens tall -- composing one mid-fling is a hundred-millisecond frame.
The block splitting built later (for draw granularity) is the missing
piece: with one item per markdown *block*, entering composition costs
laying out a paragraph, and the parse is already cached by the same
warm() that always ran. So the transcript is now a
LazyColumn(reverseLayout) over TranscriptUnits -- settled replies
flattened to block items, everything else one item, the live reply kept
whole because its text changes per delta.

What each constraint rests on now, all verified on the emulator against
a real 1200-event imported transcript at --delay 120:

- Following the newest message, paging in history, and the keyboard are
  the reversed layout itself: item zero is the bottom, an arriving
  message extends the pinned end, a page lands past every visible
  index, and an IME resize keeps the anchored item against the
  composer. A short conversation stacks from the bottom.
- No item enters unready: pages are warmed before the fold lands (the
  opening page now folds a scratch copy off-thread first), so heights
  are real on first measure and scrolling back is cache hits -- zero
  markdown parses on the composing thread across a full page-back
  through all 1200 events.
- Restore resolves the saved seq to a unit index and snaps before the
  draw gate lifts; anchors gained a unit ordinal (ScrollAnchor grew a
  third field, read compatibly) so a position inside a forty-block
  reply survives.
- Unloaded history is a spinner item at the far end while moreHistory
  holds; paging triggers on estimated pixels ahead (units remaining at
  the typical visible unit size), since a lazy list has not measured
  what it never composed.
- The tap-half expansion anchoring survives, but through
  requestScrollToItem: a raw dispatchRawDelta from onSizeChanged forces
  remeasure inside the measure pass and crashes
  ("performMeasureAndLayout called during measure").

Deleted: TranscriptScroll.kt entirely (1000 lines of window, spacers,
tops, anchors, seeding). ParsedReplies gained a parts cache so the
per-fold flatten never re-scans a settled message, and clear() now
empties all three maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-31 14:30:02 -04:00
1 parent 0a0949ee7f
commit acb59adcf6
8 files changed
+492 -1128

No files matched your search

@@ -180,8 +180,17 @@ class ParsedReplies {
*/ */
private val blocks = ConcurrentHashMap<String, List<String>>() private val blocks = ConcurrentHashMap<String, List<String>>()
/**
* How each message divides into prose and memory notes, cached for the same reason as
* [blocksOf]: [transcriptUnits] asks per fold, and the regex scan behind [messageParts] is
* proportional to the message every time where a lookup is proportional to nothing.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
fun blocksOf(text: String): List<String> = blocks.computeIfAbsent(text) { markdownBlocks(it) } fun blocksOf(text: String): List<String> = blocks.computeIfAbsent(text) { markdownBlocks(it) }
fun partsOf(text: String): List<MessagePart> = parts.computeIfAbsent(text) { messageParts(it) }
/** The parse of [text] -- the one made ahead, or one made now. */ /** The parse of [text] -- the one made ahead, or one made now. */
fun of(text: String): State = fun of(text: String): State =
parsed[text]?.also { DebugStats.count("markdown ready") } parsed[text]?.also { DebugStats.count("markdown ready") }
@@ -205,5 +214,9 @@ class ParsedReplies {
} }
/** Everything these described is gone; see [ParsedReplies]. */ /** Everything these described is gone; see [ParsedReplies]. */
fun clear() = parsed.clear() fun clear() {
parsed.clear()
blocks.clear()
parts.clear()
}
} }
@@ -33,7 +33,7 @@ fun AssistantMessage(
live: Boolean = false, live: Boolean = false,
) { ) {
DebugStats.count("message composed") DebugStats.count("message composed")
val parts = remember(text) { partsOf(text) } val parts = remember(text) { messageParts(text) }
val only = parts.singleOrNull() val only = parts.singleOrNull()
if (only is MessagePart.Prose) { if (only is MessagePart.Prose) {
BlockedMarkdown(only.text, replies, modifier, live) BlockedMarkdown(only.text, replies, modifier, live)
@@ -57,8 +57,11 @@ fun AssistantMessage(
* here rather than at the two places that need the answer, because [markdownIn] has to name the * 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 * 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. * reports, and the row pays the parse in the frame it appears, which is the cost being removed.
*
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold, so the scan happens once per message.
*/ */
private fun partsOf(text: String): List<MessagePart> { fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text) val parts = splitMemoryNotes(text)
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
} }
@@ -69,10 +72,10 @@ private fun partsOf(text: String): List<MessagePart> {
* A string warmed under a key no row ever looks up is a miss that nothing reports, so this has to * A string warmed under a key no row ever looks up is a miss that nothing reports, so this has to
* name what the rows actually draw rather than what the message contains. * name what the rows actually draw rather than what the message contains.
*/ */
fun markdownIn(text: String): List<String> = partsOf(text).map { it.text } fun markdownIn(text: String): List<String> = messageParts(text).map { it.text }
@Composable @Composable
private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
// Named, not just tinted: a colour can say "this one is different", but it cannot say // Named, not just tinted: a colour can say "this one is different", but it cannot say
@@ -92,5 +92,5 @@ fun BlockedMarkdown(
} }
} }
/** The gap between one block of a reply and the next. */ /** The gap between one block of a reply and the next, here and in [transcriptUnits]. */
private val BLOCK_SPACING = 6.dp val BLOCK_SPACING = 6.dp
@@ -16,11 +16,12 @@ private const val ANCHORS = "session-scroll"
* with -- so an active session renames its tool runs every time it is reopened, and an anchor * with -- so an active session renames its tool runs every time it is reopened, and an anchor
* naming one is never found. A seq is the server's own numbering, assigned once and never moved. * naming one is never found. A seq is the server's own numbering, assigned once and never moved.
* *
* [offset] is how far into that row the viewport starts, in pixels, and is the reason this is a * [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and
* pair rather than a bare seq: a reader stopped halfway down a long tool output is put back halfway * [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone
* down it. * is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway
* down it is put back at that block, not at the reply.
*/ */
data class ScrollAnchor(val seq: Long, val offset: Int) data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/** /**
* On this device rather than on the backend, which is where this app otherwise keeps state so every * On this device rather than on the backend, which is where this app otherwise keeps state so every
@@ -32,9 +33,12 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored = val stored =
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null) context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
?: return null ?: return null
val seq = stored.substringBefore(':').toLongOrNull() ?: return null val fields = stored.split(':')
val offset = stored.substringAfter(':').toIntOrNull() ?: return null val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
return ScrollAnchor(seq, offset) val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the
// closest older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
} }
/** /**
@@ -48,6 +52,6 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
if (anchor == null) remove(sessionId) if (anchor == null) remove(sessionId)
else putString(sessionId, "${anchor.seq}:${anchor.offset}") else putString(sessionId, "${anchor.seq}:${anchor.offset}:${anchor.unit}")
} }
} }
@@ -8,6 +8,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -18,6 +20,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -43,11 +46,15 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@@ -64,6 +71,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -148,6 +156,20 @@ private class LastHeight {
var value: Int? = null var value: Int? = null
} }
/**
* Which row the last touch landed in, and whether it landed in the row's top half -- which is the
* end that row should hold when it changes height; see [holdTopEdge].
*
* One slot rather than a map, because only the touch that is about to toggle something matters:
* [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each
* *visible* row -- the lazy list is what makes that affordable, since only rows on screen have one
* and it runs on touch, not per frame. Not snapshot state: nothing composes from it.
*/
private class LastTouch {
var key: Any? = null
var high = false
}
/** /**
* Keeps this row's top edge where it is when the row changes height, if it was asked to. * Keeps this row's top edge where it is when the row changes height, if it was asked to.
* *
@@ -672,11 +694,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// it is the question "where did I leave off" and the answer stops being interesting the // it is the question "where did I leave off" and the answer stops being interesting the
// moment the list is on screen. // moment the list is on screen.
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
// Whether the history the saved position needs is still being fetched. Nothing is drawn while // Whether the saved position is still being put back -- the history it needs fetched, and the
// it is: opening at the newest end and then travelling to the anchor is exactly the journey // scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling
// this layout exists to remove, and this transcript is not allowed to move under a reader. // to the anchor is exactly the journey a reader must never see, and this transcript is not
// The other half of the wait is [TranscriptScroll.settling], which covers the frames between // allowed to move under one.
// the rows arriving and the layout that measures them putting the position back.
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) } var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
// Sent, but not yet read by the session -- which is when the backend // Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below // records it and it comes back as a row. Until then it is drawn below
@@ -699,19 +720,29 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because
// it describes that session's rows and nothing else. // it describes that session's rows and nothing else.
val replies = remember(summary.id) { ParsedReplies() } val replies = remember(summary.id) { ParsedReplies() }
val listState = rememberTranscriptScroll(summary.id) // Keyed like everything else that describes one session's transcript. `rememberLazyListState`
// Whether the newest message is on screen right now. The content hangs from its newest end // saves through `rememberSaveable`, and this screen restores by its own anchor instead --
// (see [TranscriptScroll]), so being there is being at scroll position zero. This is what the // two restores would fight over the first frame.
// jump-to-newest button watches: it is about what the reader can see. val listState = remember(summary.id) { LazyListState() }
// Whether the newest message is on screen right now. The list is reversed, so the newest end
// is the scrolling start: nothing behind you is exactly being at the bottom. Asked of the
// scroll state rather than of item indices, because a zero-height first item (the empty
// "below" slot) makes an index ambiguous about where the viewport actually is.
// //
// It is also the gate on everything the list draws -- see [record]. // This is what the jump-to-newest button watches, and the gate on recording -- see [record].
val atNewest by remember { derivedStateOf { listState.atNewest } } val atNewest by remember { derivedStateOf { !listState.canScrollBackward } }
// Transcript events that arrived while somebody was reading further back, in the order they // Transcript events that arrived while somebody was reading further back, in the order they
// arrived, waiting for them to return to the newest end. See [record] for why. // arrived, waiting for them to return to the newest end. See [record] for why.
var held by remember { mutableStateOf(listOf<SeqEvent>()) } var held by remember { mutableStateOf(listOf<SeqEvent>()) }
// What is actually drawn: the transcript with runs of adjacent tool // What is actually drawn: the transcript with runs of adjacent tool
// calls folded into one row each. // calls folded into one row each, flattened into the list's units.
val rows = remember(items) { groupToolRuns(items) } val rows = remember(items) { groupToolRuns(items) }
val units = remember(rows) { transcriptUnits(rows, replies) }
// The same list, readable from effects launched before this composition: an effect's closure
// keeps the values of the composition that launched it, and both the anchor saver and the
// restore need the units as they are *now*.
val currentUnits by rememberUpdatedState(units)
val lastTouch = remember { LastTouch() }
/** /**
* Everything the transcript list draws, from one event. * Everything the transcript list draws, from one event.
@@ -823,10 +854,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
* and its heading and foot bar land in the halves they are already in; a single call is one * and its heading and foot bar land in the halves they are already in; a single call is one
* card, and tapping low on an open one shuts it downward exactly as the bar does. * card, and tapping low on an open one shuts it downward exactly as the bar does.
* *
* The correction itself belongs to the measurement -- see [holdTopEdge]. * The correction itself belongs to the measurement -- see [holdTopEdge]. Which half was touched
* comes from the row's own detector ([LastTouch]), written by the gesture that is about to run
* [toggle].
*/ */
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) { fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
if (listState.tappedHigh(row.startSeq)) topEdgeHeld.key = row.key if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
toggle() toggle()
} }
@@ -955,15 +988,23 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// stream then starts from where that page ended, so it carries live // stream then starts from where that page ended, so it carries live
// events only -- which is what it is good at. // events only -- which is what it is good at.
LaunchedEffect(summary.id) { LaunchedEffect(summary.id) {
// Whether the list ended up where the reader left it. False covers every way it did not
// -- no saved position, a row that is no longer in the transcript, a page that never
// arrived -- and all of them mean the same thing to the list: this is the newest end now,
// so follow it.
var restored = false
try { try {
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) } val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
// Warmed before the fold lands rather than after: flattening the rows into units
// splits every settled reply ([transcriptUnits]), and the flatten runs in the
// composition that first sees the rows. Folded into a scratch list off this thread
// to find out what needs warming; the real fold below also maintains the queue and
// the cursor, so it cannot be reused here.
withContext(Dispatchers.IO) {
var scratch = listOf<TranscriptItem>()
page.forEach { entry ->
if (entry.event !is SessionEvent.UsageDelta) {
scratch = foldEvent(scratch, entry)
}
}
warm(replies, scratch)
}
page.forEach { apply(it) } page.forEach { apply(it) }
warm(replies, items)
// Then back where reading stopped. An anchor deeper than the newest page is exactly // Then back where reading stopped. An anchor deeper than the newest page is exactly
// the one worth restoring -- somebody who read to the bottom has no anchor at all -- // the one worth restoring -- somebody who read to the bottom has no anchor at all --
// and the cost was already paid on the way down there. // and the cost was already paid on the way down there.
@@ -998,25 +1039,25 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
val span = oldestSeq - anchor.seq + HISTORY_PAGE val span = oldestSeq - anchor.seq + HISTORY_PAGE
if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break
} }
// Both writes before this coroutine yields, so the layout that first measures
// these rows is also the one that puts the position back -- the transcript is
// drawn where it was left rather than drawn and then moved. The pixels do not
// exist until that measurement, which is why the anchor is handed to the layout
// rather than applied here; see [TranscriptScroll.pending].
restoring = false
// Resolved to the row that *holds* the saved position rather than passed // Resolved to the row that *holds* the saved position rather than passed
// straight through, because the two are not always the same seq: the events // straight through, because the two are not always the same seq: the events
// behind a row regroup between the save and the reopen -- a run of calls folds // behind a row regroup between the save and the reopen -- a run of calls folds
// differently when a page boundary moves, two halves of a reply become one // differently when a page boundary moves, two halves of a reply become one
// message -- and the layout can only recognise a row by the seq it now starts // message. Null is a row that is no longer in the transcript at all -- a reset
// at. Handing it the saved seq meant the row it named no longer existed, so the // stream, or a session cleared from elsewhere -- and means there is nothing to
// position was never applied and the transcript opened at the newest end. // put back: the list is already at the newest end, which is where it opens.
//
// Null is a row that is no longer in the transcript at all -- a reset stream, or
// a session cleared from elsewhere.
anchorRow(anchor.seq)?.let { rowSeq -> anchorRow(anchor.seq)?.let { rowSeq ->
listState.restore(ScrollAnchor(rowSeq, anchor.offset)) // The units are built by composition, and this coroutine has been loading
restored = true // rows the composition may not have seen -- so wait for the build that
// holds the anchor's row before turning it into an index. Guaranteed to
// arrive, because the row is in `items` and the units are a pure function
// of it. Nothing is drawn during the wait: [restoring] gates drawing, and
// the scroll is applied before it is lifted, so there is no frame showing
// anywhere else. One past the index, because item zero is the "below" slot.
val index =
snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) }
.first { it != null }!!
listState.scrollToItem(index + 1, anchor.offset)
} }
} }
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -1024,8 +1065,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// slow but complete. Saying so beats silently showing nothing. // slow but complete. Saying so beats silently showing nothing.
streamError = e.message streamError = e.message
} }
// Nothing to put back, so draw where the content already hangs: the newest end.
if (!restored) listState.giveUp()
// Whatever happened above, including a page that never arrived: an empty transcript is a // Whatever happened above, including a page that never arrived: an empty transcript is a
// state the screen can draw, and a permanently blank one is not. // state the screen can draw, and a permanently blank one is not.
restoring = false restoring = false
@@ -1129,62 +1168,80 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// that, and it left the old position recorded -- so the reader pressed the control that means // that, and it left the old position recorded -- so the reader pressed the control that means
// "take me to the end", left, came back, and was put back where they had been. // "take me to the end", left, came back, and was put back where they had been.
// //
// There is no separate "did they choose to be here" flag any more, and there is nothing left // The place is the first visible item -- in this reversed list, the one at the *bottom* of
// for one to protect against. It existed because a keyed lazy list moved its own anchor when a // the viewport -- named by its row's seq and its unit within the row, which are the two
// row arrived, so for one frame the position reported itself scrolled back from the newest end // things that survive a reopen. The index does not (the transcript is fetched newest-first),
// when nobody had scrolled at all. This layout hangs from that end, so a row arriving does not // and the key does not either (a tool run is renamed when the newest page starts somewhere
// move the position: zero still means the newest message, during the frame it lands and after. // new); see [ScrollAnchor].
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { if (listState.scroll.isScrollInProgress) null else listState.scroll.value } snapshotFlow {
if (listState.isScrollInProgress) null
else
Triple(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
listState.canScrollBackward,
)
}
// The value `snapshotFlow` emits on collection is where the list sits before anybody // The value `snapshotFlow` emits on collection is where the list sits before anybody
// has touched it, which is not somewhere they left off. Taking it as one wiped every // has touched it, which is not somewhere they left off. Taking it as one wiped every
// saved anchor on the way in -- before the restore above could use it. // saved anchor on the way in -- before the restore above could use it.
.drop(1) .drop(1)
.collect { settled -> .collect { settled ->
if (settled == null || listState.settling) return@collect if (settled == null || restoring) return@collect
val (index, offset, awayFromNewest) = settled
saveScrollAnchor( saveScrollAnchor(
context, context,
summary.id, summary.id,
// Nothing to restore at the newest end, which is where a session with no // Nothing to restore at the newest end, which is where a session with no
// anchor opens anyway -- so the ordinary case costs a `remove` and no // anchor opens anyway -- so the ordinary case costs a `remove` and no
// page-back on the way in. // page-back on the way in. One *before* the index, because item zero is
if (settled == 0) null else listState.anchor(), // the "below" slot; a viewport starting inside it is at the newest end.
if (!awayFromNewest) null
else
currentUnits.getOrNull(index - 1)?.let {
ScrollAnchor(it.seq, offset, it.ordinal)
},
) )
} }
} }
// Reaching the far end of what is loaded fetches the page before it. // Reaching the far end of what is loaded fetches the page before it.
// //
// Measured in pixels of scroll, which is the unit the question is actually about: how far can // The question is pixels of scroll -- how far can the reader keep going before they run out
// the reader keep going before they run out. Rows are the wrong unit for it and were the // -- and a lazy list cannot answer it exactly, because it has never measured the items it
// reason this went wrong twice -- a row is anything from one line to a screenful, so a cushion // has not composed. So the room ahead is *estimated*: the units past the last visible one,
// of "three rows" is a different amount of reading depending which three, and a page of eight // at the typical size of the units that are on screen. A unit is at most a block of a reply,
// hundred *events* can fold into almost no new rows at all when it is one streamed reply and a // which is what makes the estimate usable where a count of rows was not -- a row is anything
// run of tool calls. Nothing then asked for the next page, and the transcript only loaded when // from one line to twenty-five screens, a block is roughly a paragraph. Being wrong is
// somebody dragged it again. // cheap and one-sided in effect: too low fetches a page early, too high is corrected a few
// frames later as the real sizes scroll in, and the spinner item stands at the edge for
// whatever slips through.
// //
// There is no correction beside this one any more. Following the newest message used to be an // There is no correction beside this one. Following the newest message is not an effect:
// effect here too, watching the item count and the viewport for a change and snapping back -- // the list is reversed, so an arriving message extends the end the viewport is pinned to,
// and it had to be told not to fire during a scroll, because a page of history landing mid- // and a page of history lands past every visible index and moves nothing.
// fling looked exactly like a message arriving and threw the reader to the bottom. The content
// now hangs from the newest end, so an arriving message needs no correction and a page of
// history moves nothing; see [TranscriptScroll].
LaunchedEffect(listState, moreHistory) { LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.roomAbove to listState.viewport } snapshotFlow {
.collect { (room, viewport) -> val info = listState.layoutInfo
if (!moreHistory || loadingHistory || viewport == 0) return@collect val visible = info.visibleItemsInfo
val cushion = viewport * HISTORY_SCREENS if (visible.isEmpty()) null
if (room >= cushion) return@collect else
Triple(
info.totalItemsCount - 1 - visible.last().index,
visible.sumOf { it.size } / visible.size,
info.viewportSize.height,
)
}
.collect { measured ->
val (ahead, typical, viewport) = measured ?: return@collect
if (restoring || !moreHistory || loadingHistory || viewport == 0) return@collect
if (ahead.toLong() * typical >= viewport.toLong() * HISTORY_SCREENS) return@collect
loadingHistory = true loadingHistory = true
try { try {
// One page, and then this fires again if it was not enough. // One page, and then this fires again if it was not enough -- the estimate
// // is re-made from what the page actually added, so a page that folds into
// Deliberately not a loop: how much room a page bought is a fact about the // almost no new units is followed by another because the room genuinely
// *layout* it produced, and no layout has happened yet inside this coroutine // did not grow.
// -- so a loop would be re-reading the height from before the page it just
// fetched and would ask for the whole conversation. Letting the measurement
// answer means each page is checked against what it actually added, and a
// page that folds into almost no new rows is followed by another because the
// room genuinely did not grow.
loadOlderPage() loadOlderPage()
} catch (_: ApiException) { } catch (_: ApiException) {
// Leave `moreHistory` alone: the next scroll asks again. // Leave `moreHistory` alone: the next scroll asks again.
@@ -1356,10 +1413,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
" Android ${Build.VERSION.RELEASE}", " Android ${Build.VERSION.RELEASE}",
transcript = transcript =
listOf( listOf(
" ${items.size} events, ${rows.size} rows loaded", " ${items.size} events, ${rows.size} rows," +
" content ${listState.contentHeight}px," + " ${units.size} units loaded",
" viewport ${listState.viewport}px," + " viewport" +
" room above ${listState.roomAbove}px", " ${listState.layoutInfo.viewportSize.height}px," +
" ${listState.layoutInfo.visibleItemsInfo.size}" +
" units visible",
" ${expandedTools.size} tool calls and" + " ${expandedTools.size} tool calls and" +
" ${expandedGroups.size} groups open", " ${expandedGroups.size} groups open",
), ),
@@ -1413,27 +1472,21 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) )
} }
// Every loaded row composed and kept, hanging from the newest message. // The transcript, reversed: item zero is the newest message and sits at the bottom, so
// the first frame of a session is already the right one, and following new content is
// where the list is rather than a correction it makes; see [TranscriptList].
// //
// The obvious arrangement -- oldest first, then scroll to the end -- opens at the top and // Drawn only once there is nothing left to put back. Held out of the drawing rather
// travels the whole transcript to get where it belongs. On an imported session that was // than out of the composition, so the restore's scroll is applied against a list that
// nine hundred rows measured before anything was readable, seen as the view visibly racing // is fully built, and there is no frame in which the transcript is somewhere other
// downward every time it opened. Scrolling the content in reverse removes the journey // than where it was left.
// rather than hiding it: position zero *is* the newest message, so the first frame is val settled = !restoring
// already the right one and nothing has to be scrolled at all. See [TranscriptScroll].
//
// Drawn only once there is nothing left to put back, and measured throughout -- the
// heights are what a saved position is expressed in, so the rows have to be laid out
// before it can be applied. Held out of the drawing rather than out of the list, so there
// is no frame in which the transcript is somewhere other than where it was left.
val settled = !restoring && !listState.settling
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
TranscriptColumn( TranscriptList(
rows = rows, units = units,
state = listState, state = listState,
contentPadding = TRANSCRIPT_PADDING, moreHistory = moreHistory,
spacing = TRANSCRIPT_SPACING,
modifier = modifier =
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
below = { below = {
@@ -1442,7 +1495,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// yet taken in themselves. What the session is *doing* about them is a // yet taken in themselves. What the session is *doing* about them is a
// line below, in [SessionStatusRow]. // line below, in [SessionStatusRow].
if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) {
Column(horizontalAlignment = Alignment.End) { // The gap the arrangement no longer provides: this item sits flush
// against the newest message otherwise.
Column(
Modifier.padding(top = TRANSCRIPT_SPACING),
horizontalAlignment = Alignment.End,
) {
waitingCommands.forEach { (_, text) -> waitingCommands.forEach { (_, text) ->
CommandBubble(text, waiting = true) CommandBubble(text, waiting = true)
} }
@@ -1458,9 +1516,52 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
}, },
) { row -> ) { unit ->
DebugStats.count("row composed") when (unit) {
Box(Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }) { is TranscriptUnit.Block -> MarkdownText(unit.text, replies)
is TranscriptUnit.Memory -> MemoryNote(unit.part, replies)
is TranscriptUnit.Whole -> {
val row = unit.row
Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
// A *request*, not a raw scroll delta: this runs inside
// the measure pass that discovered the new height, and a
// raw delta forces a synchronous remeasure from within
// measure, which is fatal
// ("performMeasureAndLayout called during measure").
// The request is applied by the same frame's next
// remeasure, so the correction still lands before
// anything is drawn. Reads unobserved, or this row's
// measure would inherit the scroll position as a
// dependency and remeasure on every frame of every
// fling.
Snapshot.withoutReadObservation {
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
(listState.firstVisibleItemScrollOffset + grew)
.coerceAtLeast(0),
)
}
}
// Which half of this row the touch landed in, for
// [toggleAnchored].
// On the initial pass and consuming nothing, so every control
// inside
// still gets the gesture exactly as it would have; only visible
// rows
// have one, which is what makes a detector per row affordable.
.pointerInput(row.key) {
awaitEachGesture {
val down =
awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Initial,
)
lastTouch.key = row.key
lastTouch.high = down.position.y < size.height / 2f
}
}
) {
when (row) { when (row) {
is TranscriptRow.Tools -> is TranscriptRow.Tools ->
ToolGroup( ToolGroup(
@@ -1475,8 +1576,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
}, },
isToolExpanded = { it in expandedTools }, isToolExpanded = { it in expandedTools },
// Anchored on the group, not the call: opening one call makes // Anchored on the group, not the call: opening one call
// the whole group taller, and the heading the reader is under // makes
// the whole group taller, and the heading the reader is
// under
// is the group's. // is the group's.
onToolToggle = { id -> onToolToggle = { id ->
toggleAnchored(row) { toggleAnchored(row) {
@@ -1495,7 +1598,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) )
} }
}, },
image = { ref -> SessionImage(settings, summary.id, ref) }, image = { ref ->
SessionImage(settings, summary.id, ref)
},
) )
is TranscriptRow.Single -> is TranscriptRow.Single ->
when (val item = row.item) { when (val item = row.item) {
@@ -1507,14 +1612,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
images = item.images, images = item.images,
) )
is TranscriptItem.AssistantMsg -> is TranscriptItem.AssistantMsg ->
// Only the last row can still be arriving, and only a row // A whole assistant row is only ever the reply
// that is still arriving earns a layer per block; see // still
// [BlockedMarkdown]. // arriving -- every settled reply is flattened into
AssistantMessage( // block units instead; see [transcriptUnits]. Live
item.text, // is
replies, // what earns its blocks a layer each while deltas
live = row === rows.lastOrNull(), // land.
) AssistantMessage(item.text, replies, live = true)
is TranscriptItem.ToolRun -> is TranscriptItem.ToolRun ->
ToolCard( ToolCard(
tool = item, tool = item,
@@ -1564,7 +1669,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Text( Text(
item.text, item.text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color =
MaterialTheme.colorScheme.onSurfaceVariant,
) )
is TranscriptItem.CommandRow -> CommandBubble(item.text) is TranscriptItem.CommandRow -> CommandBubble(item.text)
is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.ClearedNote -> ClearedRow()
@@ -1588,6 +1694,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
} }
} }
} }
}
}
// Still finding out what this conversation is: the newest page has not arrived, or // Still finding out what this conversation is: the newest page has not arrived, or
// it has and the list is being put back where reading stopped. Both draw no rows at // it has and the list is being put back where reading stopped. Both draw no rows at
@@ -1626,7 +1734,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// is no separate flag to set -- which is what this press used to forget, // is no separate flag to set -- which is what this press used to forget,
// landing the reader at the bottom with new messages not bringing the view // landing the reader at the bottom with new messages not bringing the view
// with them. // with them.
onClick = { scope.launch { listState.scroll.scrollTo(0) } }, onClick = { scope.launch { listState.scrollToItem(0) } },
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier =
@@ -0,0 +1,104 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The transcript: a lazy list of [TranscriptUnit]s, laid out in reverse.
*
* Reverse layout is what makes the two insertions this list gets free rather than corrected. Item
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A
* conversation shorter than the screen stacks from the bottom, hanging from the composer.
*
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker. An item the framework composes is drawn the same frame it is
* placed, and an item off screen is not a node at all.
*
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it -- so entering
* composition costs laying out one paragraph, not parsing a message.
*/
@Composable
fun TranscriptList(
units: List<TranscriptUnit>,
state: LazyListState,
moreHistory: Boolean,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
unit: @Composable (TranscriptUnit) -> Unit,
) {
LazyColumn(
state = state,
reverseLayout = true,
contentPadding = TRANSCRIPT_PADDING,
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which half;
// see [drawAccounting]. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record("measure: the whole transcript", System.nanoTime() - started)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the whole transcript",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
},
) {
// The bottom of the screen: what is waiting to be read sits under the newest message.
item(key = "below", contentType = "below") { below() }
items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) {
val u = units[it]
DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what it
// reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
)
}
}
}
}
}
/** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
/** Smaller than the whole-screen loading spinner: it stands in for a page, not for everything. */
private val HISTORY_SPINNER = 24.dp
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,135 @@
package com.example.aiapp
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* One item of the transcript list: a whole row, or one block of a settled reply.
*
* The unit of laziness is deliberately smaller than a message. A lazy list pays to compose an item
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here; the
* block splitting existed only inside the row, where the list could not see it.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
*/
@Immutable
sealed class TranscriptUnit {
/** The list identity; must survive pages landing at either end. See [TranscriptRow.key]. */
abstract val key: Any
/** Where this unit's row starts in the transcript -- the anchor identity, never the key. */
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end.
*
* What a saved scroll position carries besides the seq: a reply split into forty blocks needs
* more than "somewhere in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
/** The gap drawn above this unit -- between rows, or between blocks of one reply. */
abstract val gap: Dp
/** A row drawn as itself: a bubble, a tool card, a group -- or the reply still arriving. */
data class Whole(val row: TranscriptRow, override val gap: Dp) : TranscriptUnit() {
override val key: Any
get() = row.key
override val seq: Long
get() = row.startSeq
override val ordinal: Int
get() = 0
}
/** One markdown block of a settled reply. */
data class Block(
override val seq: Long,
override val ordinal: Int,
val text: String,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "b$seq:$ordinal"
}
/** One memory note of a settled reply; see [MemoryNote]. */
data class Memory(
override val seq: Long,
override val ordinal: Int,
val part: MessagePart.Remembered,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "m$seq:$ordinal"
}
}
/**
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its blocks ([markdownBlocks], via the caches on [replies] so a
* message is only ever split once). The reply still arriving -- the last row -- stays whole: its
* text changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. [AssistantMessage]'s own streaming path already parses deltas off
* the main thread and gives the live message a layer per block.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
* path: [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] are lookups for any text [warm] has
* seen, and a miss -- the one message that just finished streaming -- costs its split exactly once.
*/
fun transcriptUnits(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptUnit> {
val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, row ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.AssistantMsg && index != rows.lastIndex) {
var ordinal = 0
fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING
replies.partsOf(item.text).forEach { part ->
when (part) {
is MessagePart.Prose ->
replies.blocksOf(part.text).forEach { block ->
units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap())
ordinal++
}
is MessagePart.Remembered -> {
units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap())
ordinal++
}
}
}
} else {
units += TranscriptUnit.Whole(row, rowGap)
}
}
units.reverse()
return units
}
/**
* Where the unit named by a saved position sits in [units], or null if its row is not loaded.
*
* The row is found by [seq] and the unit within it by [ordinal], settling for the nearest older
* unit when the exact one is gone -- a reply regrouped by a page boundary can split into a
* different number of blocks than it had when the position was saved, and "a little above where
* they stopped" loses less than the newest end does.
*/
fun unitIndexFor(units: List<TranscriptUnit>, seq: Long, ordinal: Int): Int? {
var best: Int? = null
var bestOrdinal = -1
units.forEachIndexed { index, unit ->
if (unit.seq == seq && unit.ordinal <= ordinal && unit.ordinal > bestOrdinal) {
best = index
bestOrdinal = unit.ordinal
}
}
return best ?: units.indexOfFirst { it.seq == seq }.takeIf { it >= 0 }
}