Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-31 02:02:07 -04:00
commit d829a77ddf
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)
@@ -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>()) }
// 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
@@ -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<TranscriptItem.AssistantMsg>().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
@@ -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)
}