diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LongReply.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LongReply.kt deleted file mode 100644 index 343b0aa..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/LongReply.kt +++ /dev/null @@ -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" - } -} 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 51f6057..560df80 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -60,15 +60,10 @@ private fun partsOf(text: String): List { /** * 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 = - partsOf(text).flatMap { part -> listOfNotNull(part.text, shortenedReply(part.text)) } +fun markdownIn(text: String): List = partsOf(text).map { it.text } @Composable private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index c540c08..7d3b277 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -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) 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 4123b50..3622401 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -45,9 +45,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 @@ -142,21 +140,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 @@ -643,10 +626,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 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()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } // What this session is set to now, seeded from the row that opened it and @@ -714,12 +693,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().lastOrNull()?.seq } /** * Everything the transcript list draws, from one event. @@ -833,8 +806,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() } @@ -1423,21 +1396,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 @@ -1448,8 +1414,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 @@ -1476,43 +1442,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 @@ -1566,8 +1503,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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index af2b8b9..2a30c21 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -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): List { 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) -> 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) -> 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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt index 15b8515..2e281e0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptScroll.kt @@ -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() + private val heights = HashMap() + private var order: List = emptyList() + private var spacing = 0 + private var padTop = 0 + + internal fun laidOut(order: List, 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? = 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) }