Stop paying a callback per row per frame, and drop the reply cap

Holding every row alive turned two per-row callbacks from a cost paid by
whatever was on screen into a cost paid by everything ever loaded, which
is why loading one more page of history made the whole transcript lag
and each page after made it worse. Both fire for every registered node
on every layout pass:

  - an `onGloballyPositioned` per row, kept so a tap could be told which
    half of the row it landed in
  - an `onGloballyPositioned` per clickable inside a row, for the same
    reason, so a tool group paid several

Neither is needed. One gesture detector on the whole list records where
a touch went down in the content's own coordinates, and the row's own
start is added up from heights when somebody actually taps -- so the
transcript keeps a height per row, which changes when the row does, in
place of a position per row, which is wrong the moment anything scrolls.
Positions for the saved-scroll anchor come from the same sum, and the
anchor is applied once per layout from the content rather than once per
row.

The reply cap is gone at Iris's request; she wasn't seeing it and does
not want it for now. It was doing measurable work -- p50 rises from 16ms
to 20ms on the emulator with it removed -- so it is worth knowing where
to look if long history feels heavy.

That 4ms is the only figure here I trust. This emulator's own noise
between identical repeats is larger than the difference the callback
change makes (identical builds measured 3.5% and 9.3% of frames over
budget), and its p50 of 16ms is already past a 120Hz budget, so it
cannot rank any of this the way the phone will. The callbacks are gone
because they are O(rows) per frame by construction and the list no
longer bounds how many rows there are -- not because a number here says
so.

Checked on the emulator against a real 1,200-event transcript: tapping a
collapsed row expands it with its top edge held exactly still, and a
scroll position comes back pixel-identical after leaving the session and
returning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 01:58:48 -04:00
1 parent ee1c493559
commit f22c96abe2
6 files changed
+149 -259

No files matched your search

@@ -1,101 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* How much of a reply is drawn before the reader is offered the rest.
*
* A limit on the *source*, not on the height, and that is the whole point. Clipping a laid-out row
* to a height saves nothing: Compose measures the text and then throws the overflow away, so the
* line-breaking has already happened. Cutting the string before it is parsed is what stops the work
* from being done -- and it stops the parse too, which the height version could never reach.
*
* Four thousand characters is about two screenfuls of body text on a phone. Two rather than one so
* that a reply somewhat over the limit is not cut nearly in half, and so a reader who does not
* press anything still gets more than they can see at once.
*
* The measurement that made this worth having, from the ai-app-2 session on 2026-08-30: one message
* in a real transcript is over 14,000px tall -- seven screens -- and a single frame spent 59.6ms in
* `measureAndLayout` when it entered the viewport. That cost is proportional to the whole row
* however little of it is on screen, and it is paid again every time the row comes back.
*/
private const val REPLY_CAP_CHARS = 4000
/**
* How far past the cap a reply has to be before it is worth cutting.
*
* Without this a message of 4,001 characters loses one character and gains a button, which is worth
* nothing to anybody and costs a control that has to be read and decided about. The row that needs
* this treatment is several times the limit, not just over it.
*/
private const val REPLY_CAP_SLACK = 1000
/**
* The opening of [text] if it is long enough to be worth cutting, or null if it should be drawn
* whole.
*
* Cut at a line ending, because a markdown source cut mid-line is a different document: half a
* heading marker, a list item with no bullet, a link whose closing bracket is in the part that was
* dropped. A whole number of lines is the coarsest cut that cannot invent syntax.
*
* A fence left open by the cut is closed, which is the one case a line boundary does not save. An
* unterminated ``` swallows the rest of the reply into a code block, so the truncation would change
* how the part still on screen is *drawn* rather than only how much of it there is -- and a reader
* has no way to tell that from the reply genuinely having been code.
*/
fun shortenedReply(text: String, limit: Int = REPLY_CAP_CHARS): String? {
if (text.length <= limit + REPLY_CAP_SLACK) return null
val cut = text.lastIndexOf('\n', limit).let { if (it <= 0) limit else it }
val head = text.substring(0, cut)
// Fences are counted rather than matched: an opening and a closing one are the same token, so
// an odd number of them is an opening that never closed.
return if (head.split("\n").count { it.trimStart().startsWith("```") } % 2 == 1) "$head\n```"
else head
}
/**
* A reply drawn to [REPLY_CAP_CHARS], with the rest a press away.
*
* The control says how much is behind it rather than only "Show more", because the two answers a
* reader wants are different sizes of decision: another paragraph is worth opening while standing
* in a scroll, and another twenty screens is worth knowing about first.
*
* Expanded is remembered by the screen rather than by this row, so scrolling away and back does not
* shut something the reader deliberately opened -- see SessionScreen's other expansion sets.
*/
@Composable
fun CappedReply(
full: String,
shortened: String,
replies: ParsedReplies,
onShowMore: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.fillMaxWidth()) {
AssistantMessage(shortened, replies)
TextButton(onClick = onShowMore) {
Text(
"Show the rest (${remaining(full.length - shortened.length)})",
style = MaterialTheme.typography.labelLarge,
)
}
}
}
/** What is left, in the units a reader thinks in rather than in characters. */
private fun remaining(chars: Int): String {
// Against the same figure the cap is written in, so the two cannot drift: a "screenful" here is
// whatever [REPLY_CAP_CHARS] is two of.
val screens = chars.toDouble() / (REPLY_CAP_CHARS / 2)
return when {
screens < 1.5 -> "about another screen"
screens < 20 -> "about ${Math.round(screens)} more screens"
else -> "more than 20 screens"
}
}
@@ -60,15 +60,10 @@ private fun partsOf(text: String): List<MessagePart> {
/**
* Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready.
*
* Both forms of a long one, because which gets drawn is not decided here: the newest reply is drawn
* whole and every other long one is drawn cut (see [shortenedReply]), and the reader can ask for
* the rest of any of them. Warming only one of the two would leave the other parsing on the thread
* that draws, in the frame the row appears -- and a string warmed under a key no row ever looks up
* is a miss that nothing reports. The extra parse is off the composing thread, which is the only
* place it would have cost anything.
* 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<String> =
partsOf(text).flatMap { part -> listOfNotNull(part.text, shortenedReply(part.text)) }
fun markdownIn(text: String): List<String> = partsOf(text).map { it.text }
@Composable
private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
@@ -1,5 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -31,11 +32,11 @@ import androidx.compose.ui.unit.dp
fun PeerMessageRow(
item: TranscriptItem.PeerNote,
expanded: Boolean,
onToggle: (Float) -> Unit,
onToggle: () -> Unit,
replies: ParsedReplies,
modifier: Modifier = Modifier,
) {
Card(modifier.fillMaxWidth().clickableAt(onToggle)) {
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
@@ -46,9 +46,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -143,21 +141,6 @@ private class TopEdgeHold {
var key: Any? = null
}
/**
* Where a row is on screen, so a tap on it can be told which half it landed in.
*
* Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click,
* and observed by nothing.
*/
private class RowBounds {
var top = 0f
var height = 0f
/** Above this is the row's top half, below it the bottom half. */
val middle
get() = top + height / 2
}
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
private class LastHeight {
var value: Int? = null
@@ -644,10 +627,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// by default, which is the rule for anything new in this transcript: a screen that opens
// everything it can is one nobody can scan.
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
// Long replies the reader has asked to see the rest of, by the seq of the row. Held here
// rather than in the row so that scrolling away and back does not shut something they
// deliberately opened -- the same reason the sets above it are here.
var expandedReplies by remember { mutableStateOf(setOf<Long>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
// What this session is set to now, seeded from the row that opened it and
@@ -715,12 +694,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// What is actually drawn: the transcript with runs of adjacent tool
// calls folded into one row each.
val rows = remember(items) { groupToolRuns(items) }
// The reply drawn whole however long it is; see the transcript list below. Recomputed with
// `items` rather than tracked as it arrives, because "newest" moves: a reply that was the
// last one becomes history the moment the next turn starts, and a row that kept its
// exemption after that would be the one enormous row this exists to bound.
val newestReply =
remember(items) { items.filterIsInstance<TranscriptItem.AssistantMsg>().lastOrNull()?.seq }
/**
* Everything the transcript list draws, from one event.
@@ -834,8 +807,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
*
* The correction itself belongs to the measurement -- see [holdTopEdge].
*/
fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) {
if (at < row.middle) topEdgeHeld.key = key
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
if (listState.tappedHigh(row.startSeq)) topEdgeHeld.key = row.key
toggle()
}
@@ -1422,21 +1395,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
},
) { row ->
val bounds = remember { RowBounds() }
Box(
Modifier.onGloballyPositioned {
bounds.top = it.positionInRoot().y
bounds.height = it.size.height.toFloat()
}
.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }
) {
Box(Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }) {
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
onToggle = {
toggleAnchored(row) {
expandedGroups =
if (row.id in expandedGroups)
expandedGroups - row.id
@@ -1447,8 +1413,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// 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, at ->
toggleAnchored(row.key, bounds, at) {
onToolToggle = { id ->
toggleAnchored(row) {
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
@@ -1475,43 +1441,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
text = item.text,
images = item.images,
)
is TranscriptItem.AssistantMsg -> {
// The newest reply is never cut. It is the one being read
// as it arrives -- often still arriving -- and putting a
// "show the rest" under a turn somebody is waiting for
// hides the answer they are waiting for. Every reply
// behind it is history, and history is what this is for.
val shortened =
if (
item.seq == newestReply ||
item.seq in expandedReplies
)
null
else remember(item.text) { shortenedReply(item.text) }
if (shortened == null) {
AssistantMessage(item.text, replies)
} else {
CappedReply(
full = item.text,
shortened = shortened,
replies = replies,
onShowMore = {
// Anchored like every other row that changes
// height, so the edge the reader touched
// stays where it is.
toggleAnchored(row.key, bounds, bounds.top) {
expandedReplies = expandedReplies + item.seq
}
},
)
}
}
is TranscriptItem.AssistantMsg ->
AssistantMessage(item.text, replies)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
onToggle = {
toggleAnchored(row) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
@@ -1565,8 +1502,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
item = item,
expanded = item.seq in expandedNotes,
replies = replies,
onToggle = { at ->
toggleAnchored(row.key, bounds, at) {
onToggle = {
toggleAnchored(row) {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
@@ -2,8 +2,6 @@ package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Row
@@ -25,10 +23,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -132,38 +126,6 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
return rows
}
/** Where a click went down, and what it went down on. See [clickableAt]. */
private class TapPoint {
var coords: LayoutCoordinates? = null
var y = 0f
}
/**
* Clickable, and tells the click where on the screen the finger went down.
*
* A row holds still the end nearest the tap when it changes height, so the toggle has to say where
* it was touched. It cannot say which *end* it was: a group is one row with a control at each end
* and a call in the middle, and only the row knows where its own ends are. So this reports a
* position in root coordinates and leaves the meaning to whoever owns the row.
*
* Built on `clickable` rather than replacing it, because `clickable` is what draws the ripple and
* what puts a click action in front of assistive technology. The position is read on the initial
* pass and nothing is consumed, so the click still happens exactly as it would have.
*/
@Composable
fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier {
val tap = remember { TapPoint() }
return onGloballyPositioned { tap.coords = it }
.pointerInput(Unit) {
awaitEachGesture {
val down =
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
tap.y = tap.coords?.localToRoot(down.position)?.y ?: 0f
}
}
.clickable { onClick(tap.y) }
}
/**
* Several calls under one heading, closed until somebody asks.
*
@@ -188,17 +150,19 @@ fun ToolGroup(
group: TranscriptRow.Tools,
expanded: Boolean,
/**
* Told where it was pressed, because this row has a control at each end -- see [clickableAt].
* Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are, so the row records the touch
* itself and this just says that one happened.
*/
onToggle: (Float) -> Unit,
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String, Float) -> Unit,
onToolToggle: (String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit,
) {
val heading = "Called ${group.calls.size} tools"
if (!expanded) {
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
@@ -214,7 +178,7 @@ fun ToolGroup(
) {
val barHeight = groupBarHeight()
Row(
Modifier.fillMaxWidth().height(barHeight).clickableAt(onToggle),
Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -231,7 +195,7 @@ fun ToolGroup(
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { at -> onToolToggle(call.id, at) },
onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer,
image = image,
shape = connectedShape(index, group.calls.size),
@@ -264,10 +228,10 @@ private fun groupBarHeight(): Dp {
* calls sit on is the same thickness at both ends. See [groupBarHeight].
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: (Float) -> Unit) {
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().height(height).clickableAt(onToggle).semantics {
Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics {
contentDescription = "Collapse these tool calls"
},
horizontalArrangement = Arrangement.Center,
@@ -324,7 +288,7 @@ private val GROUP_GAP = 2.dp
fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: (Float) -> Unit,
onToggle: () -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
@@ -333,7 +297,7 @@ fun ToolCard(
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickableAt(onToggle), shape = shape) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
@@ -1,6 +1,8 @@
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
@@ -18,8 +20,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -54,17 +60,62 @@ import androidx.compose.ui.unit.dp
class TranscriptScroll(internal val scroll: ScrollState) {
/**
* Where each row's top edge sits inside the content, by the seq that names it.
* How tall each row is, by the seq that names it, and the order they are drawn in.
*
* Written from the layout pass as rows are placed, so it describes the layout that is on
* screen. 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.
* 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 tops = HashMap<Long, Int>()
private val heights = HashMap<Long, Int>()
private var order: List<Long> = emptyList()
private var spacing = 0
private var padTop = 0
internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) {
this.order = order
this.spacing = spacing
this.padTop = padTop
}
internal fun height(seq: Long, height: Int) {
heights[seq] = height
}
/** Where the last touch went down, in the content's own coordinates. */
private var tapY = 0f
internal fun touched(y: Float) {
tapY = y
}
/**
* A position waiting to be put back, applied by the layout that first places its row.
* 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
@@ -79,19 +130,31 @@ class TranscriptScroll(internal val scroll: ScrollState) {
val settling: Boolean
get() = pending != null
internal fun placed(seq: Long, top: Int) {
tops[seq] = top
pending?.let { anchor ->
if (anchor.seq != seq) return@let
scroll.dispatchRawDelta(
(scroll.maxValue - top - anchor.offset - scroll.value).toFloat()
)
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
}
/** 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 += (heights[s] ?: return null) + spacing
}
return null
}
/** Everything these described is gone -- a stream reset, or a different session. */
fun clear() = tops.clear()
fun clear() = heights.clear()
/** Whether the newest message is on screen. See the class comment: the newest end is zero. */
val atNewest: Boolean
@@ -116,8 +179,14 @@ class TranscriptScroll(internal val scroll: ScrollState) {
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.
val at = tops.entries.filter { it.value <= top }.maxByOrNull { it.value } ?: return null
return ScrollAnchor(at.key, top - at.value)
var y = padTop
var found: Pair<Long, Int>? = null
for (s in order) {
if (y > top) break
found = s to y
y += (heights[s] ?: return null) + spacing
}
return found?.let { (seq, rowTop) -> ScrollAnchor(seq, top - rowTop) }
}
/**
@@ -173,22 +242,47 @@ fun TranscriptColumn(
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.
remember(rows, spacing, contentPadding, density) {
state.laidOut(
rows.map { it.startSeq },
with(density) { spacing.roundToPx() },
with(density) { contentPadding.calculateTopPadding().roundToPx() },
)
layoutDirection
}
Column(
modifier
.verticalScroll(state.scroll, reverseScrolling = true)
.padding(contentPadding)
.heightIn(min = viewportHeight)
.fillMaxWidth(),
.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.
.onPlaced { state.placed() }
// 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.
.pointerInput(Unit) {
awaitEachGesture {
state.touched(
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
.position
.y
)
}
},
verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom),
) {
rows.forEach { item ->
// Keyed so that a row keeps its composition -- and so the state inside it, an open
// tool call or an expanded reply, stays with the row rather than with the position.
// tool call, stays with the row rather than with the position.
key(item.key) {
Column(
Modifier.fillMaxWidth().onPlaced {
state.placed(item.startSeq, it.positionInParent().y.toInt())
}
Modifier.fillMaxWidth().onSizeChanged { state.height(item.startSeq, it.height) }
) {
row(item)
}