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 } +}