From acb59adcf6c1cf5911fa514bd887610ebcba8ebe Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 14:30:02 -0400 Subject: [PATCH] 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 --- .../main/kotlin/com/example/aiapp/Markdown.kt | 15 +- .../kotlin/com/example/aiapp/MemoryNote.kt | 11 +- .../kotlin/com/example/aiapp/MessageBlocks.kt | 4 +- .../kotlin/com/example/aiapp/ScrollAnchor.kt | 20 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 502 +++++---- .../com/example/aiapp/TranscriptList.kt | 104 ++ .../com/example/aiapp/TranscriptScroll.kt | 1003 ----------------- .../com/example/aiapp/TranscriptUnits.kt | 135 +++ 8 files changed, 579 insertions(+), 1215 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt 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 1e226c5..57a3b16 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -180,8 +180,17 @@ class ParsedReplies { */ private val blocks = ConcurrentHashMap>() + /** + * 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>() + fun blocksOf(text: String): List = blocks.computeIfAbsent(text) { markdownBlocks(it) } + fun partsOf(text: String): List = parts.computeIfAbsent(text) { messageParts(it) } + /** The parse of [text] -- the one made ahead, or one made now. */ fun of(text: String): State = parsed[text]?.also { DebugStats.count("markdown ready") } @@ -205,5 +214,9 @@ class ParsedReplies { } /** Everything these described is gone; see [ParsedReplies]. */ - fun clear() = parsed.clear() + fun clear() { + parsed.clear() + blocks.clear() + parts.clear() + } } 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 f2c1a89..a3edd1c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -33,7 +33,7 @@ fun AssistantMessage( live: Boolean = false, ) { DebugStats.count("message composed") - val parts = remember(text) { partsOf(text) } + val parts = remember(text) { messageParts(text) } val only = parts.singleOrNull() if (only is MessagePart.Prose) { 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 * 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. + * + * 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 { +fun messageParts(text: String): List { val parts = splitMemoryNotes(text) return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts } @@ -69,10 +72,10 @@ private fun partsOf(text: String): List { * 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. */ -fun markdownIn(text: String): List = partsOf(text).map { it.text } +fun markdownIn(text: String): List = messageParts(text).map { it.text } @Composable -private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { +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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt index bc4ffb1..d65bb10 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt @@ -92,5 +92,5 @@ fun BlockedMarkdown( } } -/** The gap between one block of a reply and the next. */ -private val BLOCK_SPACING = 6.dp +/** The gap between one block of a reply and the next, here and in [transcriptUnits]. */ +val BLOCK_SPACING = 6.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt index 8d63f1b..7587858 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt @@ -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 * 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 - * pair rather than a bare seq: a reader stopped halfway down a long tool output is put back halfway - * down it. + * [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and + * [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone + * 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 @@ -32,9 +33,12 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { val stored = context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null) ?: return null - val seq = stored.substringBefore(':').toLongOrNull() ?: return null - val offset = stored.substringAfter(':').toIntOrNull() ?: return null - return ScrollAnchor(seq, offset) + val fields = stored.split(':') + val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null + 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?) { context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { if (anchor == null) remove(sessionId) - else putString(sessionId, "${anchor.seq}:${anchor.offset}") + else putString(sessionId, "${anchor.seq}:${anchor.offset}:${anchor.unit}") } } 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 7744b44..893ecaf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -8,6 +8,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts 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.Column 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.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -43,11 +46,15 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.platform.LocalContext import androidx.compose.ui.semantics.contentDescription @@ -64,6 +71,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -148,6 +156,20 @@ private class LastHeight { 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. * @@ -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 // moment the list is on screen. val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } - // Whether the history the saved position needs is still being fetched. Nothing is drawn while - // it is: opening at the newest end and then travelling to the anchor is exactly the journey - // this layout exists to remove, and this transcript is not allowed to move under a reader. - // The other half of the wait is [TranscriptScroll.settling], which covers the frames between - // the rows arriving and the layout that measures them putting the position back. + // Whether the saved position is still being put back -- the history it needs fetched, and the + // scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling + // to the anchor is exactly the journey a reader must never see, and this transcript is not + // allowed to move under one. var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) } // 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 @@ -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 // it describes that session's rows and nothing else. val replies = remember(summary.id) { ParsedReplies() } - val listState = rememberTranscriptScroll(summary.id) - // Whether the newest message is on screen right now. The content hangs from its newest end - // (see [TranscriptScroll]), so being there is being at scroll position zero. This is what the - // jump-to-newest button watches: it is about what the reader can see. + // Keyed like everything else that describes one session's transcript. `rememberLazyListState` + // saves through `rememberSaveable`, and this screen restores by its own anchor instead -- + // two restores would fight over the first frame. + 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]. - val atNewest by remember { derivedStateOf { listState.atNewest } } + // This is what the jump-to-newest button watches, and the gate on recording -- see [record]. + val atNewest by remember { derivedStateOf { !listState.canScrollBackward } } // 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. var held by remember { mutableStateOf(listOf()) } // 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 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. @@ -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 * 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) { - if (listState.tappedHigh(row.startSeq)) topEdgeHeld.key = row.key + if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key toggle() } @@ -955,15 +988,23 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // stream then starts from where that page ended, so it carries live // events only -- which is what it is good at. 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 { 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() + page.forEach { entry -> + if (entry.event !is SessionEvent.UsageDelta) { + scratch = foldEvent(scratch, entry) + } + } + warm(replies, scratch) + } page.forEach { apply(it) } - warm(replies, items) // 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 -- // 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 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 // 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 // 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 - // at. Handing it the saved seq meant the row it named no longer existed, so the - // position was never applied and the transcript opened at the newest end. - // - // Null is a row that is no longer in the transcript at all -- a reset stream, or - // a session cleared from elsewhere. + // message. Null is a row that is no longer in the transcript at all -- a reset + // stream, or a session cleared from elsewhere -- and means there is nothing to + // put back: the list is already at the newest end, which is where it opens. anchorRow(anchor.seq)?.let { rowSeq -> - listState.restore(ScrollAnchor(rowSeq, anchor.offset)) - restored = true + // The units are built by composition, and this coroutine has been loading + // 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) { @@ -1024,8 +1065,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // slow but complete. Saying so beats silently showing nothing. 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 // state the screen can draw, and a permanently blank one is not. 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 // "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 - // for one to protect against. It existed because a keyed lazy list moved its own anchor when a - // row arrived, so for one frame the position reported itself scrolled back from the newest end - // when nobody had scrolled at all. This layout hangs from that end, so a row arriving does not - // move the position: zero still means the newest message, during the frame it lands and after. + // The place is the first visible item -- in this reversed list, the one at the *bottom* of + // the viewport -- named by its row's seq and its unit within the row, which are the two + // things that survive a reopen. The index does not (the transcript is fetched newest-first), + // and the key does not either (a tool run is renamed when the newest page starts somewhere + // new); see [ScrollAnchor]. 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 // 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. .drop(1) .collect { settled -> - if (settled == null || listState.settling) return@collect + if (settled == null || restoring) return@collect + val (index, offset, awayFromNewest) = settled saveScrollAnchor( context, summary.id, // 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 - // page-back on the way in. - if (settled == 0) null else listState.anchor(), + // page-back on the way in. One *before* the index, because item zero is + // 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. // - // Measured in pixels of scroll, which is the unit the question is actually about: how far can - // the reader keep going before they run out. Rows are the wrong unit for it and were the - // reason this went wrong twice -- a row is anything from one line to a screenful, so a cushion - // of "three rows" is a different amount of reading depending which three, and a page of eight - // hundred *events* can fold into almost no new rows at all when it is one streamed reply and a - // run of tool calls. Nothing then asked for the next page, and the transcript only loaded when - // somebody dragged it again. + // The question is pixels of scroll -- how far can the reader keep going before they run out + // -- and a lazy list cannot answer it exactly, because it has never measured the items it + // has not composed. So the room ahead is *estimated*: the units past the last visible one, + // at the typical size of the units that are on screen. A unit is at most a block of a reply, + // which is what makes the estimate usable where a count of rows was not -- a row is anything + // from one line to twenty-five screens, a block is roughly a paragraph. Being wrong is + // 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 - // effect here too, watching the item count and the viewport for a change and snapping back -- - // and it had to be told not to fire during a scroll, because a page of history landing mid- - // 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]. + // There is no correction beside this one. Following the newest message is not an effect: + // the list is reversed, so an arriving message extends the end the viewport is pinned to, + // and a page of history lands past every visible index and moves nothing. LaunchedEffect(listState, moreHistory) { - snapshotFlow { listState.roomAbove to listState.viewport } - .collect { (room, viewport) -> - if (!moreHistory || loadingHistory || viewport == 0) return@collect - val cushion = viewport * HISTORY_SCREENS - if (room >= cushion) return@collect + snapshotFlow { + val info = listState.layoutInfo + val visible = info.visibleItemsInfo + if (visible.isEmpty()) null + 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 try { - // One page, and then this fires again if it was not enough. - // - // Deliberately not a loop: how much room a page bought is a fact about the - // *layout* it produced, and no layout has happened yet inside this coroutine - // -- 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. + // 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 + // almost no new units is followed by another because the room genuinely + // did not grow. loadOlderPage() } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. @@ -1356,10 +1413,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () " Android ${Build.VERSION.RELEASE}", transcript = listOf( - " ${items.size} events, ${rows.size} rows loaded", - " content ${listState.contentHeight}px," + - " viewport ${listState.viewport}px," + - " room above ${listState.roomAbove}px", + " ${items.size} events, ${rows.size} rows," + + " ${units.size} units loaded", + " viewport" + + " ${listState.layoutInfo.viewportSize.height}px," + + " ${listState.layoutInfo.visibleItemsInfo.size}" + + " units visible", " ${expandedTools.size} tool calls and" + " ${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 - // travels the whole transcript to get where it belongs. On an imported session that was - // nine hundred rows measured before anything was readable, seen as the view visibly racing - // downward every time it opened. Scrolling the content in reverse removes the journey - // rather than hiding it: position zero *is* the newest message, so the first frame is - // 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 + // Drawn only once there is nothing left to put back. Held out of the drawing rather + // than out of the composition, so the restore's scroll is applied against a list that + // is fully built, and there is no frame in which the transcript is somewhere other + // than where it was left. + val settled = !restoring Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.fillMaxSize()) { - TranscriptColumn( - rows = rows, + TranscriptList( + units = units, state = listState, - contentPadding = TRANSCRIPT_PADDING, - spacing = TRANSCRIPT_SPACING, + moreHistory = moreHistory, modifier = Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, 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 // line below, in [SessionStatusRow]. 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) -> CommandBubble(text, waiting = true) } @@ -1458,73 +1516,76 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () } } }, - ) { row -> - DebugStats.count("row composed") - Box(Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }) { - when (row) { - is TranscriptRow.Tools -> - ToolGroup( - group = row, - expanded = row.id in expandedGroups, - onToggle = { - toggleAnchored(row) { - expandedGroups = - if (row.id in expandedGroups) - expandedGroups - row.id - else expandedGroups + row.id - } - }, - isToolExpanded = { it in expandedTools }, - // Anchored on the group, not the call: opening one call makes - // the whole group taller, and the heading the reader is under - // is the group's. - onToolToggle = { id -> - toggleAnchored(row) { - expandedTools = - if (id in expandedTools) expandedTools - id - else expandedTools + id - } - }, - onAnswer = { questionId, answers -> - act { - answerQuestion( - settings, - summary.id, - questionId, - answers, + ) { unit -> + when (unit) { + 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), ) } - }, - image = { ref -> SessionImage(settings, summary.id, ref) }, - ) - is TranscriptRow.Single -> - when (val item = row.item) { - is TranscriptItem.UserMsg -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = item.text, - images = item.images, - ) - is TranscriptItem.AssistantMsg -> - // Only the last row can still be arriving, and only a row - // that is still arriving earns a layer per block; see - // [BlockedMarkdown]. - AssistantMessage( - item.text, - replies, - live = row === rows.lastOrNull(), - ) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, + } + // 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) { + is TranscriptRow.Tools -> + ToolGroup( + group = row, + expanded = row.id in expandedGroups, onToggle = { + toggleAnchored(row) { + expandedGroups = + if (row.id in expandedGroups) + expandedGroups - row.id + else expandedGroups + row.id + } + }, + isToolExpanded = { it in expandedTools }, + // Anchored on the group, not the call: opening one call + // makes + // the whole group taller, and the heading the reader is + // under + // is the group's. + onToolToggle = { id -> toggleAnchored(row) { expandedTools = - if (item.id in expandedTools) - expandedTools - item.id - else expandedTools + item.id + if (id in expandedTools) expandedTools - id + else expandedTools + id } }, onAnswer = { questionId, answers -> @@ -1541,49 +1602,96 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () SessionImage(settings, summary.id, ref) }, ) - is TranscriptItem.QuestionCard -> - QuestionRow(item) { answers -> - act { - answerQuestion( - settings, - summary.id, - item.id, - answers, + is TranscriptRow.Single -> + when (val item = row.item) { + is TranscriptItem.UserMsg -> + UserBubble( + settings = settings, + sessionId = summary.id, + text = item.text, + images = item.images, ) - } - } - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> - SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - is TranscriptItem.CommandRow -> CommandBubble(item.text) - is TranscriptItem.ClearedNote -> ClearedRow() - is TranscriptItem.CompactedNote -> CompactedRow(item) - is TranscriptItem.PeerNote -> - PeerMessageRow( - item = item, - expanded = item.seq in expandedNotes, - replies = replies, - onToggle = { - toggleAnchored(row) { - expandedNotes = - if (item.seq in expandedNotes) - expandedNotes - item.seq - else expandedNotes + item.seq + is TranscriptItem.AssistantMsg -> + // A whole assistant row is only ever the reply + // still + // arriving -- every settled reply is flattened into + // block units instead; see [transcriptUnits]. Live + // is + // what earns its blocks a layer each while deltas + // land. + AssistantMessage(item.text, replies, live = true) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { + toggleAnchored(row) { + expandedTools = + if (item.id in expandedTools) + expandedTools - item.id + else expandedTools + item.id + } + }, + onAnswer = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answers -> + act { + answerQuestion( + settings, + summary.id, + item.id, + answers, + ) + } } - }, - ) + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> + SessionImage(settings, summary.id, item.ref) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onSurfaceVariant, + ) + is TranscriptItem.CommandRow -> CommandBubble(item.text) + is TranscriptItem.ClearedNote -> ClearedRow() + is TranscriptItem.CompactedNote -> CompactedRow(item) + is TranscriptItem.PeerNote -> + PeerMessageRow( + item = item, + expanded = item.seq in expandedNotes, + replies = replies, + onToggle = { + toggleAnchored(row) { + expandedNotes = + if (item.seq in expandedNotes) + expandedNotes - item.seq + else expandedNotes + item.seq + } + }, + ) + } } + } } } } @@ -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, // landing the reader at the bottom with new messages not bringing the view // with them. - onClick = { scope.launch { listState.scroll.scrollTo(0) } }, + onClick = { scope.launch { listState.scrollToItem(0) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt new file mode 100644 index 0000000..bb6f460 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt @@ -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, + 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt deleted file mode 100644 index cc1cacc..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ /dev/null @@ -1,1003 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -/** - * The transcript's scroll position, addressed the way the transcript itself is: a row, and how far - * that row has been scrolled past the top of the viewport. - * - * Pixels are the unit underneath because this list is a plain [Column] rather than a lazy one -- - * every loaded row is composed, measured and kept, so there is a real height for every row whether - * or not it is on screen, and no index has to stand in for one. What that buys is that scrolling - * back over a message never rebuilds it: a Compose node that is still alive and whose constraints - * have not changed is not re-measured at all (`MeasurePassDelegate.remeasure` returns early), so - * the text keeps the layout it was given. A lazy list disposes a row the moment it leaves the - * viewport, and the markdown tree, the measured lines and the cached paragraph go with it -- which - * is the lag when scrolling back over history that has already been read. - * - * The content is laid out oldest-first and scrolled in reverse, which is what makes the two - * insertions this list actually gets free rather than corrected: - * - * - `reverseScrolling` places the content by its *end*, so `value == 0` is the newest message and - * growing the content at the far end -- a page of older history landing -- moves nothing on - * screen. `maxValue` grows and `value` is left alone. - * - A new message extends the same end the viewport is pinned to, so following it is not an effect - * that has to notice and correct: at `value == 0` the newest content is simply what is on screen. - * The keyboard opening is the same case from the other side -- the viewport shrinks, `maxValue` - * grows, and the newest message is still against the bottom. - * - * Both of those were scroll corrections in the lazy version, each with a comment explaining a way - * it had been seen to fire at the wrong moment. - */ -@Stable -class TranscriptScroll(internal val scroll: ScrollState) { - - /** - * How tall each row is, by the seq that names it, and the order they are drawn in. - * - * Heights rather than positions, and that is the whole difference between this costing nothing - * and costing the frame. A position is only correct for one layout, so keeping one per row - * meant a callback per row per frame once the list stopped disposing them -- the transcript - * lagged the moment a second page was loaded, and worse with each page after. A height changes - * when its row changes and not otherwise, and a position can be added up from heights at the - * two moments anything actually needs one: saving where the reader is, and putting it back. - * - * Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason the - * anchor is: a tool run is renamed when the newest page starts somewhere new, and a position - * recorded against the old name is never found again. - */ - private val heights = HashMap() - private var order: List = emptyList() - private var spacing = 0 - private var padTop = 0 - - /** Each row's top edge, added up from the heights before it; see [refreshTops]. */ - private var tops = IntArray(0) - private var rowIndex = HashMap() - private var topsStale = true - - internal fun laidOut(order: List, spacing: Int, padTop: Int, screen: Int) { - // The window is a range of indices, and an index means nothing across a change to the - // order. A page of older history is *prepended*, so every row moves down by the size of the - // page and the window then names rows nowhere near the reader -- the transcript blanks - // until the next frame recomputes it, which is the flicker on loading history. Held by seq - // across the rebuild, the window still names the rows it named before. - val heldFirst = if (retained.isEmpty()) null else this.order.getOrNull(retained.first) - val heldLast = if (retained.isEmpty()) null else this.order.getOrNull(retained.last) - // Where the reader is, named by a row rather than by a pixel, because the pixels are about - // to be a layout out of date. A page of older history is added above the viewport, and the - // scroll container does not know it exists until it has measured it -- so `maxValue` still - // describes the shorter transcript, and a window computed from it is wrong by the whole - // height of the page that just arrived. Measured on the emulator as a blank frame on every - // page loaded. A row keeps its identity across the change; its offset does not. - val was = if (scroll.maxValue > 0) anchor() else null - this.order = order - this.spacing = spacing - this.padTop = padTop - this.screen = screen - rowIndex = HashMap(order.size) - order.forEachIndexed { index, seq -> rowIndex[seq] = index } - topsStale = true - val first = heldFirst?.let { rowIndex[it] } - val last = heldLast?.let { rowIndex[it] } - retained = - if (first != null && last != null && first <= last) first..last else IntRange.EMPTY - // Whatever it was walking towards was named in the old indices too. - target = retained - growing = false - // Something has to be built before the first frame, or the first frame is blank. The window - // is worked out from the scroll position, and there is no scroll position until this has - // been laid out once -- so on the composition that introduces the rows, every one of them - // is outside an empty window, the whole transcript collapses to a single spacer, and the - // reader sees it flicker before the real window arrives a frame later. The newest end is - // the right guess because that is where the transcript opens; [restore] seeds the other - // case, where the reader is being put back somewhere else entirely. - // Recomputed here, in the composition that introduces the rows, rather than a frame later - // when the snapshot of the scroll position next runs: a message arriving does not move the - // view, so there is nothing for the scroll flow to notice, and the frame that first draws - // the new row would draw a spacer where it goes -- a gap between the last message and the - // box it was typed in. - // - // Except before the scroll container has measured any content, where the position it - // reports is not a position. `maxValue` is zero then, which reads as the reader being at - // the oldest end of the conversation, and a window computed from it lands a whole - // transcript away from where the session is about to open. Measured on the emulator as one - // blank frame on every session opened. The newest end is the right guess there, because it - // is where a session opens; a restore overrides it in [restore], which knows better. - // - // The test is what the *scroll container* knows, not whether there was a window before. - // Those two come apart in the case that matters: a regroup during streaming can retire the - // rows the window was named after, and answering "no window, so seed the newest end" puts a - // reader who was up in the history back at rows they were nowhere near. - if (order.isEmpty()) return - // Only when the reader's position survived the change. If it did not -- the row they were - // looking at was folded into another by a regroup, which happens whenever a page boundary - // moves -- then the window is left exactly where the remap above put it, on the same rows - // by name. The tempting fallback, the scroll container's own pixels, is the one answer - // known to be wrong here: it is a layout out of date, by the height of the page that just - // arrived, and using it put the window a whole page away from the reader. - val at = was?.let { a -> rowHolding(a.seq)?.let { tops[it] + a.offset } } - when { - // The reader's position survived the change, so the window follows it. - at != null -> trackRetained(at) - // It did not, but the window was remapped by name above and still names rows the - // reader was near. That is the better answer than anything the scroll container can - // offer, whose numbers are a layout out of date by the height of whatever just - // arrived. - !retained.isEmpty() -> DebugStats.count("laid out with a lost position") - // Nothing to go on at all -- there were no rows before this. A session opens at its - // newest end, so that is where to build. This branch used to do nothing, on the - // reasoning that a measured scroll position meant there was a position to trust; a - // scroll container that has measured a *different* session still reports one, so the - // window was left empty, the whole transcript composed as a single spacer, and the - // first frame of every session opened was blank. - else -> { - DebugStats.count("window seeded at the newest end") - seedAround(order.lastIndex) - } - } - } - - /** The window's height, for the frames before the scroll container has measured one. */ - private var screen = 0 - - /** - * How tall to treat the visible area as. - * - * The scroll container's own measurement once there is one, and the window's height before - * that. There is a first composition in which rows exist and no layout has happened, and - * answering it with zero there builds a window of nothing -- so the transcript draws blank for - * a frame, which is the flicker when a session opens. - */ - private val visible: Int - get() = scroll.viewportSize.takeIf { it > 0 } ?: screen - - /** - * Builds a screenful of rows around [index], for the frames before there is a window. - * - * Sized in pixels rather than in rows, because a row is anything from a one-line note to a - * screenful and "the last eight" is a different amount of transcript every time -- a run of - * tool calls is eight rows and less than half a screen, which would seed a window too small to - * cover the viewport and flicker anyway, which is the thing being fixed. - */ - private fun seedAround(index: Int) { - if (order.isEmpty()) return - val budget = (visible * SEED_SCREENS).coerceAtLeast(ROW_GUESS) - var first = index.coerceIn(0, order.lastIndex) - var last = first - var built = assumed(order[first]) - while (built < budget && (first > 0 || last < order.lastIndex)) { - // Downwards first: an anchor names the row at the *top* of the viewport, so the rows - // after it are the ones the reader is about to be looking at. - if (last < order.lastIndex) built += assumed(order[++last]) - if (built < budget && first > 0) built += assumed(order[--first]) - } - // In rows as well as in pixels, for the same reason the window is; see `trackRetained`. - // Nothing has been measured at all when this runs, so the pixel budget is being spent - // against a guess at every row's height. - retained = - (first - RETAIN_NOW_ROWS).coerceAtLeast(0)..(last + RETAIN_NOW_ROWS).coerceAtMost( - order.lastIndex - ) - } - - internal fun height(seq: Long, height: Int) { - val had = heights.put(seq, height) - if (had == height) return - measuredTotal += height - (had ?: 0) - if (had == null) measuredCount++ - topsStale = true - } - - private var measuredTotal = 0L - private var measuredCount = 0 - - /** - * How tall to assume a row is before anything has measured it. - * - * A row that has just been paged in has no height, and something has to stand in for one or it - * cannot be placed at all. This used to be answered by keeping every unmeasured row built -- - * which meant a page of history standing up seventeen screens of markdown inside a single - * frame, measured on a Pixel 9 Pro XL as a hundred milliseconds in one go, with the frame after - * it unable to start. An estimate lets a paged-in row be a spacer like any other distant row, - * and be built when the reader actually comes near it. - * - * The average of what has been measured, because the average row in a conversation is a good - * guess at the next one and a constant is not: these run from a one-line note to a screenful. - * Being wrong is cheap and self-correcting -- the estimate is only used above the viewport, - * where the content hangs from its far end, so a correction there moves nothing on screen. - */ - private fun assumed(seq: Long): Int = - heights[seq] - ?: if (measuredCount > 0) (measuredTotal / measuredCount).toInt() else ROW_GUESS - - /** - * The running total of every row's top edge, recomputed at most once per frame and only after - * something has actually changed height. - * - * Adding it up per row per lookup would be quadratic, and the lookup happens once per row per - * frame -- so on a long transcript the thing meant to save the frame would have been the thing - * costing it. - */ - private fun refreshTops() { - // These are offsets within the content, and the content is what the column measured -- so - // they are only positions on screen while the content is at least as tall as the viewport. - // Below that the layout modifier reports the viewport's height and places the content - // against the bottom of it, and every top here is short by the difference. Nothing depends - // on it today, because a conversation shorter than the screen is entirely retained and has - // nowhere to scroll to, but a reader of this arithmetic should know it is assuming that. - if (!topsStale) return - val out = IntArray(order.size) - var y = padTop - order.forEachIndexed { index, seq -> - out[index] = y - y += assumed(seq) + spacing - } - tops = out - topsStale = false - topsVersion++ - } - - /** Where the last touch went down, in the content's own coordinates. */ - private var tapY = 0f - - internal fun touched(y: Float) { - tapY = y - } - - /** - * Whether the last touch landed in the top half of the row named by [seq], which is the end - * that row should hold when it changes height. - * - * One detector for the whole list rather than one per row, and one lookup at the moment of the - * tap rather than a position kept current for every row. Both of the obvious arrangements cost - * a callback per row per frame once the list stopped disposing rows -- an - * `onGloballyPositioned` to know where a row is, or a gesture detector on each row to catch its - * own touches -- and together they were most of the frame: on a deep transcript they took a - * scroll from 4% of frames over budget to 47%. Neither is needed. The content knows where it - * was touched, the heights say where each row starts, and the sum is only wanted when somebody - * actually taps. - */ - fun tappedHigh(seq: Long): Boolean { - val top = topOf(seq) ?: return false - val height = heights[seq] ?: return false - return tapY < top + height / 2f - } - - /** - * A position waiting to be put back, applied by the layout that first places the content. - * - * Held here rather than applied by whoever loaded the row because the pixels do not exist yet - * at that point: a plain column has no height for a row until it has been measured. Applying it - * from the placement is what makes the restore frame-exact -- the transcript is drawn where it - * was left rather than drawn at the newest end and then moved, which is a journey the reader - * would see. [settling] is the same fact asked the other way, and gates drawing. - */ - var pending: ScrollAnchor? by mutableStateOf(null) - private set - - /** Whether a saved position is still being put back, so nothing should be drawn yet. */ - val settling: Boolean - get() = pending != null - - /** - * The content has been placed: put back a waiting position, if its row is there to hold it. - * - * Once per layout rather than once per row. Both numbers it needs are the scroll container's - * own, and those are written during measure -- so by placement they describe this layout. - */ - internal fun placed() { - val anchor = pending ?: return - val rowTop = topOf(anchor.seq) ?: return - scroll.dispatchRawDelta((scroll.maxValue - rowTop - anchor.offset - scroll.value).toFloat()) - pending = null - DebugStats.count("restored position applied") - // The jump lands somewhere the window was not computed for, and this is the last chance - // before the frame that draws. It cannot build anything by itself -- writing state during - // placement only schedules a recomposition -- which is why [restore] seeds the destination - // in advance; this widens the seed to the full window rather than replacing it. - trackRetained() - } - - /** - * The row that *holds* [seq], which is not always the row that starts with it. - * - * Rows are groups: a run of tool calls folds into one, two halves of a reply become one - * message, and which way they fold changes when a page boundary moves. So a seq recorded as a - * row's name a moment ago can be in the middle of a different row now, and a lookup by name - * alone answers null for a reader who has not gone anywhere. - */ - private fun rowHolding(seq: Long): Int? { - refreshTops() - var found: Int? = null - for ((index, start) in order.withIndex()) { - if (start > seq) break - found = index - } - return found - } - - /** Where a row's top edge sits inside the content, or null if it has not been measured. */ - private fun topOf(seq: Long): Int? { - var y = padTop - for (s in order) { - if (s == seq) return y - y += assumed(s) + spacing - } - return null - } - - /** Everything these described is gone -- a stream reset, or a different session. */ - fun clear() = heights.clear() - - /** Whether the newest message is on screen. See the class comment: the newest end is zero. */ - val atNewest: Boolean - get() = scroll.value == 0 - - /** - * How much unread history sits above the viewport, in pixels. - * - * This is the paging question asked in the unit it is actually about. Counting rows could not - * answer it: a row is anything from one line to a screenful, so "three rows back" is a - * different amount of reading depending on which three, and the count that mattered -- how far - * somebody can scroll before running out -- was never what was measured. - */ - val roomAbove: Int - get() = scroll.maxValue - scroll.value - - private fun rowTop(seq: Long): Int? { - val index = rowIndex[seq] ?: return null - refreshTops() - return tops.getOrNull(index) - } - - /** - * Whether the row named by [seq] is close enough to be worth keeping composed at all. - * - * The window that could not be avoided. Keeping every loaded row alive is what makes scrolling - * back over a message cost nothing, and it is also the thing whose cost grows with the - * conversation rather than with the screen -- measured on a Pixel 9 Pro XL as a step: smooth - * with one page loaded, worse at the next, worse again at the one after, with the frame going - * into the draw phase while almost nothing was being recorded. - * - * Eight screens either side is about sixteen times what a lazy list keeps, which is the whole - * point: everything somebody has just read stays built, and only a deliberate journey back - * through the conversation pays to rebuild anything. A row outside it is replaced by a spacer - * of the height it was last measured at, so the transcript's total height does not change and - * nothing under the reader moves. - * - * A row that has never been measured stands in at the average of those that have, so it is - * placed and judged like any other; see [assumed]. Keeping every unmeasured row instead is what - * made a page of history land as one hundred-millisecond frame -- seventeen screens of markdown - * shaped at once, because "not measured yet" described the whole page. - */ - /** - * Which rows are built, as one piece of state every row reads rather than a question every row - * asks. - * - * Each row used to hold a `derivedStateOf` over the scroll position. That reads correctly and - * costs the same shape as the draw-phase culling it replaced: every one of those derived states - * is invalidated by every scroll frame and has to be re-evaluated to find out whether its - * answer changed, so the per-frame work grew with the number of loaded rows again -- this time - * landing in the recomposition pass, which the platform reports as the frame's animation phase. - * Measured on a Pixel 9 Pro XL at 153 rows: 11.6ms of it at the median, against an 8.3ms frame. - * - * One shared range costs one evaluation a frame, and rows are only disturbed when it actually - * moves. - */ - var retained: IntRange by mutableStateOf(IntRange.EMPTY) - private set - - /** - * How tall rows [run] stand in for as a single spacer, the gaps between them included. - * - * The gaps have to be counted here because collapsing a run removes children from the column, - * and `Arrangement.spacedBy` puts a gap between children rather than after each one -- so a run - * of `k` rows drawn as one spacer is `k - 1` gaps shorter than the rows were unless it says so. - * Getting that wrong does not look like a spacing bug; it shortens the content, which moves - * everything the reader is looking at. - */ - /** - * The spacers that stand in for rows [run], because one of them will not always do. - * - * `Modifier.height` turns into a fixed `Constraints`, and Compose packs a Constraints into a - * single Long -- with a width of zero it has eighteen bits for the height, so anything over - * 262,143px throws `Can't represent a width of 0 and height of N in Constraints` and takes the - * app down during measure. Collapsing every stood-down row into one spacer is what made that - * reachable: a long conversation scrolled to the newest end has its whole history above the - * window, and one seen here was 273,238px of it. - * - * Split rather than clamped, because the height is load-bearing -- it is what holds the - * transcript's total the same as the rows it replaces, and shortening it would move everything - * under the reader. The gaps the arrangement inserts between the pieces come out of the total - * for the same reason. - */ - fun spacerHeights(run: IntRange): List { - val total = runHeight(run) - if (total <= 0) return emptyList() - if (total <= SPACER_MAX) return listOf(total) - var pieces = 2 - while (true) { - val body = total - (pieces - 1) * spacing - if (body <= 0) return listOf(total.coerceAtMost(SPACER_MAX)) - if ((body + pieces - 1) / pieces <= SPACER_MAX) { - val each = body / pieces - return List(pieces) { each + if (it == 0) body - each * pieces else 0 } - } - pieces++ - } - } - - fun runHeight(run: IntRange): Int { - if (run.isEmpty() || order.isEmpty()) return 0 - var total = 0 - for (index in run) total += assumed(order[index]) - return total + (run.last - run.first) * spacing - } - - /** - * [retained] clamped to a list of [count] rows, or empty if nothing should be built yet. - * - * The range is worked out against the order from the last layout, and a page of history landing - * changes that order in the same composition that reads this -- so an index from before it can - * be past the end. Clamping here rather than at the read sites keeps one answer to it. - */ - /** - * The window the list last actually composed rows for. - * - * Not [retained], which is the *intent*. The two came apart the moment the window started being - * recomputed during layout: it is then updated before the frame draws, so a check against it - * passes while the screen is still showing the spacers from the composition before -- and - * [covered] reported zero for a transcript that was visibly flickering. A plain field rather - * than state, because nothing should recompose when it changes; it is a record of what already - * happened. - */ - private var built: IntRange = IntRange.EMPTY - - internal fun building(range: IntRange) { - if (range.isEmpty() && order.isNotEmpty()) DebugStats.count("composed no rows at all") - built = range - } - - fun window(count: Int): IntRange { - if (count == 0 || retained.isEmpty()) return IntRange.EMPTY - val first = retained.first.coerceIn(0, count - 1) - val last = retained.last.coerceIn(first, count - 1) - return first..last - } - - /** - * Recomputes [retained] if the view has moved far enough to be worth it. - * - * Deliberately lazy about it. Every row reads the range, so every change to it recomposes all - * of them -- which is affordable once every couple of screens and is not affordable at the row - * boundaries, where a fling would cross one every few frames. The window is eight screens - * either side and this moves it in steps of two, so the margin absorbs the staleness. - */ - /** Where the window is heading. [retained] walks towards it rather than jumping; see below. */ - private var target: IntRange = IntRange.EMPTY - - /** Whether [retained] is still short of [target], so somebody should keep stepping it. */ - var growing: Boolean by mutableStateOf(false) - private set - - /** - * Widens the built range towards its target, a few rows at a time. False when it has arrived. - * - * Standing rows up is not free and its cost is not recomposition -- it is laying the text out, - * which means shaping every glyph, and that is on the thread drawing the frame. Moving the - * window in one go meant two screens of markdown shaped inside a single frame at each step, and - * seventeen screens of it in the frame a session opens in. The platform files that under the - * frame's draw phase, which is why it never showed up in the counters here: nothing is being - * *recorded*, it is being measured. - * - * Spreading it over frames does not make it cheaper and is not meant to. It stops it arriving - * all at once, which is the difference between a frame that is late and a frame that is missed - * by ten. - */ - internal fun standUpSome(): Boolean { - if (target.isEmpty() || retained == target) { - growing = false - return false - } - var first = retained.first - var last = retained.last - var budget = STAND_UP_PER_FRAME - while (budget > 0 && (first > target.first || last < target.last)) { - if (first > target.first) { - first-- - budget-- - } - if (budget > 0 && last < target.last) { - last++ - budget-- - } - } - retained = first..last - growing = retained != target - return growing - } - - internal fun trackRetained(from: Int? = null) = - // Without subscribing whoever called it to the scroll position. This runs from the - // composition that lays the rows out as well as from the flow that watches scrolling, and - // a composition that reads `scroll.value` recomposes on every frame of every fling -- the - // O(rows)-per-frame mistake this whole file exists to undo, arriving by the back door. - Snapshot.withoutReadObservation { - refreshTops() - // [from] when the caller knows better than the scroll container does; see `laidOut`. - val viewportTop = from ?: (scroll.maxValue - scroll.value) - // The outer bound moves lazily, because every row reads the window and moving it - // recomposes all of them -- affordable every couple of screens, and not at every row - // boundary a fling crosses. - val step = (visible * RETAIN_STEP_SCREENS).coerceAtLeast(1) - val moved = viewportTop - rangeAt - if (target.isEmpty() || topsVersion != rangeVersion || moved > step || moved < -step) { - rangeAt = viewportTop - rangeVersion = topsVersion - target = retainedRange(viewportTop, RETAIN_SCREENS) - } - // What is near the screen, every frame rather than only when the bound moves. This is - // the half that cannot be lazy and the half that was: a fling crosses a screen in a - // frame or two, so a window last widened two screens ago has already been outrun and - // the row arriving at the edge is drawn as the spacer it still is. Standing rows up a - // few at a time made it worse rather than causing it -- after a seed or a restore the - // built window is a couple of screens wide and grows two rows a frame, which a fling - // beats easily. Cheap enough to do always: one scan of the row list, and the write - // below is skipped when the answer has not changed, so nothing recomposes. - // Widened by a fixed number of rows as well as by screens, because a screen is a - // number of pixels and the rows it covers are only *estimated* until they have been - // measured -- and nothing has been measured on the frame a session opens, which is - // where this matters. Rows shorter than the running average make a two-screen window - // cover fewer rows than the screen actually shows, and the reader sees the difference - // as blank. - val screens = retainedRange(viewportTop, RETAIN_NOW_SCREENS) - val near = - (screens.first - RETAIN_NOW_ROWS).coerceAtLeast(0)..(screens.last + RETAIN_NOW_ROWS) - .coerceAtMost(order.lastIndex) - val first = if (retained.isEmpty()) near.first else minOf(retained.first, near.first) - val last = if (retained.isEmpty()) near.last else maxOf(retained.last, near.last) - // Clamped to the bound, but never past what is on screen: the bound is allowed to be a - // couple of screens out of date and this is not. - val next = - minOf(first.coerceAtLeast(target.first), near.first)..maxOf( - last.coerceAtMost(target.last), - near.last, - ) - if (next != retained) { - retained = next - growing = retained != target - } - } - - /** - * Which rows stay built, as a range of indices rather than a test each row makes for itself. - * - * A range can be guaranteed non-empty and a per-row test cannot, which is the point. Every row - * answering independently means a fault in the arithmetic stands *all* of them down at once and - * leaves a blank transcript with nothing measured, so nothing to correct it -- which is exactly - * what happened. Here the nearest row to the viewport is in the range by construction, whatever - * the numbers say, so the worst a mistake can do is build too few rows or too many. - */ - private fun retainedRange(viewportTop: Int, screens: Int): IntRange { - refreshTops() - if (order.isEmpty()) return IntRange.EMPTY - val margin = (visible * screens).coerceAtLeast(1) - // A binary search, because this runs on every frame that lays the transcript out and a scan - // of every row is the cost this whole file exists to keep out of a frame. Putting the - // window's recomputation where the layout is -- which is what made it correct -- put a - // linear scan there with it, and took a placement from 0.5ms to 1.1ms at 190 rows. The tops - // are ascending by construction, so the scan was never needed. - val first = (firstRowBelow(viewportTop - margin) - 1).coerceAtLeast(0) - val last = - (firstRowBelow(viewportTop + visible + margin) - 1).coerceIn(first, order.lastIndex) - return first..last - } - - /** - * The index of the first row that starts below [y], which is where a band's edge falls. - * - * One before it is the row *containing* [y], and clamping that at zero is what makes the range - * above always non-empty -- the property the whole window rests on, since a fault in this - * arithmetic can then only build too few rows or too many, never none. - */ - private fun firstRowBelow(y: Int): Int { - var low = 0 - var high = tops.size - while (low < high) { - val mid = (low + high) ushr 1 - if (tops[mid] <= y) low = mid + 1 else high = mid - } - return low - } - - /** - * Whether what is on screen is actually built, asked at the moment of drawing. - * - * This is the flicker, made countable. Every way the window can be stale looks the same to a - * reader -- a band of blank where a message should be, for one frame -- and "it still flickers - * sometimes" is not something a fix can be tested against. Drawing is the one place that knows - * both what is being shown and what was built, so the check belongs here even though the state - * is not this function's own. - * - * O(rows), which is affordable only because it runs when the transcript is *recorded* rather - * than when it is placed -- measured on a Pixel 9 Pro XL as 48 times in 42 seconds of reading, - * against 2204 placements. Without read observation, or asking the question would make the - * answer wrong: a scroll position read while drawing invalidates that drawing every frame. - */ - internal fun covered(): Boolean = Snapshot.withoutReadObservation { - if (order.isEmpty()) return@withoutReadObservation true - refreshTops() - val onScreen = retainedRange(scroll.maxValue - scroll.value, 0) - // Against what was composed, not against [retained]; see [built]. - if (onScreen.first >= built.first && onScreen.last <= built.last) - return@withoutReadObservation true - // Which way it fell short, and by how much, so a failure that survives this says what - // it is rather than only that it happened. - if (onScreen.first < built.first) - DebugStats.atLeast("window short above, rows", (built.first - onScreen.first).toLong()) - if (onScreen.last > built.last) - DebugStats.atLeast("window short below, rows", (onScreen.last - built.last).toLong()) - // Repaired as well as reported. This cannot rescue the frame being drawn -- building a - // row needs a composition and that is the next frame at the earliest -- but it bounds - // the damage to one frame however the window went stale, including the ways not yet - // found. The paths that are known are fixed at their causes; this stands behind them. - retained = minOf(retained.first, onScreen.first)..maxOf(retained.last, onScreen.last) - false - } - - private var rangeAt = Int.MIN_VALUE - private var rangeVersion = -1 - private var topsVersion = 0 - - /** The height of the visible area, 0 until the first measurement. */ - val viewport: Int - get() = scroll.viewportSize - - /** How tall everything loaded is, which is what a plain column has to hold laid out at once. */ - val contentHeight: Int - get() = scroll.maxValue + scroll.viewportSize - - /** Where the reader is now, or null before anything has been laid out. */ - fun anchor(): ScrollAnchor? { - val top = scroll.maxValue - scroll.value - // The row covering the top of the viewport: the last one that starts at or above it. - var y = padTop - var found: Pair? = null - for (s in order) { - if (y > top) break - found = s to y - y += assumed(s) + spacing - } - return found?.let { (seq, rowTop) -> ScrollAnchor(seq, top - rowTop) } - } - - /** - * Asks for [anchor] to be put back by the layout that places its row; see [pending]. - * - * The caller is responsible for the row being loaded. Nothing here can wait for one that never - * arrives, and a position held open for it would leave the transcript blank -- so a row that is - * not in the transcript any more is [giveUp]'s case, not this one. - */ - fun restore(anchor: ScrollAnchor) { - pending = anchor - // The destination, built now rather than discovered after the jump. [placed] moves the - // view during placement, and a window recomputed there only schedules a recomposition -- - // so the rows it would build arrive a frame after the frame that ungates drawing, and the - // reader is shown the place they were put back to as blank spacer before it fills in. - // Seeded here it is built by the composition that the jump is measured in. - DebugStats.count("window seeded around a restored position") - rowIndex[anchor.seq]?.let { seedAround(it) } - } - - /** Draw where we are instead: the anchored row is not in this transcript any more. */ - fun giveUp() { - pending = null - } - - /** - * Scrolls by [pixels] without animating, from wherever the caller is. - * - * Used to hold a row's top edge still when it changes height. Growth already goes upward - * because the content hangs from its end, so the bottom edge is held for free and this is the - * other case; see `holdTopEdge`. - */ - fun by(pixels: Int) { - scroll.dispatchRawDelta(pixels.toFloat()) - } -} - -@Composable -fun rememberTranscriptScroll(key: Any?): TranscriptScroll { - // Keyed like the state that wraps it. `rememberScrollState()` is not, so a second session - // opened without leaving the screen would inherit the first one's offset -- and, worse for the - // window, its `maxValue`, which is the number everything here decides from. - val scroll = remember(key) { ScrollState(0) } - return remember(key) { TranscriptScroll(scroll) } -} - -/** - * Every loaded row, composed and kept. See [TranscriptScroll] for why this is not a lazy list. - * - * [viewportHeight] is passed in rather than measured here because a scrollable child is measured - * with no height bound at all, so the content cannot ask how tall the visible area is. It is what - * holds a conversation shorter than the screen against the bottom, where the composer is, instead - * of leaving a gap under it that cannot be scrolled away. - */ -@Composable -fun TranscriptColumn( - rows: List, - state: TranscriptScroll, - contentPadding: PaddingValues, - spacing: Dp, - modifier: Modifier = Modifier, - below: @Composable () -> Unit, - row: @Composable (TranscriptRow) -> Unit, -) { - val density = LocalDensity.current - val layoutDirection = LocalLayoutDirection.current - // The order and the gaps, so a row's position can be added up from heights when one is wanted. - // Recomputed only when the rows change, which is what keeps every frame free of it. - // The window's own height, for the frames before the scroll container has measured one; see - // [TranscriptScroll.visible]. - val screen = LocalWindowInfo.current.containerSize.height - remember(rows, spacing, contentPadding, density, screen) { - state.laidOut( - rows.map { it.startSeq }, - with(density) { spacing.roundToPx() }, - with(density) { contentPadding.calculateTopPadding().roundToPx() }, - screen, - ) - layoutDirection - } - // One evaluation of the window a frame, for the whole list; see [TranscriptScroll.retained]. - // Scrolling only -- rows arriving is handled by `laidOut` above, in the composition that - // introduces them, because a frame later is a frame with a spacer where the new row goes. - LaunchedEffect(state) { - snapshotFlow { state.scroll.value to state.scroll.maxValue } - .collect { state.trackRetained() } - } - // Walks the window towards its target a few rows a frame; see [TranscriptScroll.standUpSome]. - // Driven from a frame callback rather than a plain loop so the rows stand up between frames - // instead of all inside one, which is the entire point of doing it gradually. - LaunchedEffect(state) { - snapshotFlow { state.growing } - .collect { - while (state.growing) { - withFrameNanos {} - state.standUpSome() - } - } - } - Column( - modifier - .verticalScroll(state.scroll, reverseScrolling = true) - .padding(contentPadding) - // As tall as the visible area at least, so a conversation shorter than the screen sits - // against the composer rather than leaving a gap under it that cannot be scrolled away. - // - // Applied to what this node *reports* rather than to what it asks its child for, and - // that distinction is the whole cost of opening the keyboard. Passed down as a minimum - // height, it changed the child's constraints on every frame of the IME animation -- - // and changed constraints are exactly what defeats the early-return in - // `MeasurePassDelegate.remeasure`, so the entire transcript was re-measured thirty - // times on the way up. Measured on a Pixel 9 Pro XL as 250 measurements averaging - // 2.8ms with a 54.5ms worst. The child is measured with the constraints it already - // had, so it early-returns, and the minimum is applied here where it belongs. It only - // ever bites on a conversation shorter than the screen, which is not the case that was - // paying for it. - // - // 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. - // Measuring the transcript and placing it are different costs with different fixes: - // one is shaping text that has changed, the other is O(rows) whether or not anything - // has. - .layout { measurable, constraints -> - val started = System.nanoTime() - val placeable = measurable.measure(constraints) - DebugStats.record("measure: the whole transcript", System.nanoTime() - started) - val height = maxOf(placeable.height, state.scroll.viewportSize) - layout(placeable.width, height) { - val placing = System.nanoTime() - // From the bottom, because that is the end the conversation hangs from. - placeable.place(0, height - placeable.height) - // Here, because this is the moment both halves of the question are current: - // the rows have just been measured, so their heights are this layout's, and - // the scroll container has just been measured, so its position is too. Every - // other place it was called from could be right about one and stale about the - // other -- a row growing when it is finally built moves every position after - // it and moves nothing the scroll flow can see, so a window last worked out - // from a scroll no longer describes the rows it names. It writes only when the - // answer changes, so a frame where nothing moved costs one scan and no - // recomposition. - state.trackRetained() - DebugStats.record("place: the whole transcript", System.nanoTime() - placing) - } - } - .fillMaxWidth() - // Once for the whole list, not once per row: this is where a saved position is put - // back, and by placement the scroll container's own measurements describe this layout. - // Only while there is a position waiting to be put back. `onPlaced` is the one hook - // here that would otherwise run on every frame, and it has nothing to do on all but - // the two frames of a restore. - .then(if (state.settling) Modifier.onPlaced { state.placed() } else Modifier) - // One gesture detector for the whole list; see [TranscriptScroll.tappedHigh]. On the - // initial pass and consuming nothing, so every control inside still gets the gesture - // exactly as it would have. - .drawWithContent { - val started = System.nanoTime() - drawContent() - DebugStats.record("draw: the whole transcript", System.nanoTime() - started) - // The flicker, counted rather than described; see [TranscriptScroll.covered]. - if (!state.covered()) DebugStats.count("drew a row that was not built") - } - .pointerInput(Unit) { - awaitEachGesture { - state.touched( - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - .position - .y - ) - } - }, - verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom), - ) { - // Everything outside the window is two spacers rather than one per row, and that is what - // stops the cost of a frame growing with the conversation. A stood-down row was still a - // layout node, and the framework's own per-frame bookkeeping after a scroll -- the position - // dispatch, the cached screen rect each node keeps -- walks live nodes rather than visible - // ones. So the transcript got slower with every page loaded even though the extra rows drew - // nothing at all: on a Pixel 9 Pro XL, a flat 7.7ms in the frame's draw phase while our own - // recording accounted for a fortieth of it. Two spacers, because [retained] is a range: the - // rows that are not in it are always one run before it and one run after. - val window = state.window(rows.size) - // What is being composed, told to the state that has to answer for it later; see - // [TranscriptScroll.building]. - state.building(window) - val ahead = if (window.isEmpty()) rows.indices else 0.. - key(index) { Spacer(Modifier.fillMaxWidth().height(with(density) { height.toDp() })) } - } -} - -@Composable -private fun RetainedRow( - state: TranscriptScroll, - item: TranscriptRow, - row: @Composable (TranscriptRow) -> Unit, -) { - Column( - Modifier.fillMaxWidth() - // A layer of its own, which is the piece of a lazy list this had not rebuilt. - // - // Without one a row's glyphs are recorded into its parent's display list, and that - // list is re-recorded every frame the parent is invalidated -- which, while the list - // is scrolling, is every frame. With one the row is recorded once and afterwards moved - // by a transform, and the render thread culls the ones off screen itself. - // - // This replaces draw-phase culling that read the scroll position from inside every - // row's drawing. That arrangement could not win, and the two are mutually exclusive: - // reading a scroll position during draw invalidates the drawing it is in, so it - // re-recorded every row on every frame in order to decide most of them need not be - // drawn. Keeping both would have bought the cost of the first and none of the second. - .graphicsLayer() - .onSizeChanged { state.height(item.startSeq, it.height) } - .drawWithContent { - val started = System.nanoTime() - drawContent() - DebugStats.record("record: one row", System.nanoTime() - started) - } - ) { - row(item) - } -} - -/** How far either side of the screen a row stays built; see [TranscriptScroll.retains]. */ -private const val RETAIN_SCREENS = 8 - -/** How far the view moves before the outer bound is worked out again; see `trackRetained`. */ -private const val RETAIN_STEP_SCREENS = 2 - -/** - * How much either side of the screen is built at once rather than a few rows at a time. - * - * Has to exceed what a fling covers between two of those recomputations, or the reader reaches a - * row before it has been built -- and then some, because what the list has actually *composed* is - * always a frame behind what the window says: the window is recomputed during layout, and the rows - * it names are built by the composition that follows. Three screens, against a bound that moves - * every two, measured down from misses of one and two rows at two screens. - */ -private const val RETAIN_NOW_SCREENS = 3 - -/** The same margin counted in rows, for when no height is known yet; see `trackRetained`. */ -private const val RETAIN_NOW_ROWS = 12 - -/** - * How many rows may be laid out in one frame while the window is catching up; see `standUpSome`. - */ -private const val STAND_UP_PER_FRAME = 2 - -/** - * How much is built before there is a scroll position to work a window out from; see `seedAround`. - */ -private const val SEED_SCREENS = 2 - -/** - * The tallest a single spacer may be; see [TranscriptScroll.spacerHeights]. - * - * Comfortably under the 262,143px that a fixed-width `Constraints` can hold, rather than at it: the - * limit depends on how many bits the width took, and a spacer that is nearly the maximum is a crash - * waiting for a wider screen. - */ -private const val SPACER_MAX = 100_000 - -/** What a row is assumed to be worth before any of them have been measured. */ -private const val ROW_GUESS = 800 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt new file mode 100644 index 0000000..396b15d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt @@ -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, replies: ParsedReplies): List { + val units = ArrayList(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, 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 } +}