Hold a row's top edge during layout, so nothing is drawn out of place

The correction ran in a coroutine, so it landed a frame or more after the
layout it was correcting: the wrong position was drawn once and then fixed,
which reads as a flick and gets worse the faster the screen refreshes. That
is a race with the display rather than a bug that can be tuned out, so the
fix is not a shorter delay but a different phase.

It now happens in the layout phase. `Modifier.holdTopEdge` learns the row's
new height from the measurement that produced it and asks the list to shift
by exactly that much, before anything is drawn. `requestScrollToItem` is
the form that may be asked for during layout; `dispatchRawDelta` is not --
it calls forceRemeasure and dies with "performMeasureAndLayout called
during measure layout", which cost one crash to establish.

The arming flag and the per-row height are deliberately not snapshot state.
Both are written from layout, where a snapshot write that composition reads
would schedule another recomposition -- another frame, which is the thing
being removed.

This also drops the machinery the previous attempt needed: no waiting on a
size change, no timeout, no marking the rows above to find one that could
still report the move. A row measures itself, so a row that shrinks out of
the viewport is no longer a special case.

Verified with ui-trace sampling at ~1kHz, where a single bad frame would
show as ten to twenty samples: expanding and collapsing from a heading are
each one step from old position to held position with nothing in between,
collapsing from the foot bar holds the four rows below it, a drag 120ms
after a tap is left alone, and scrolling back stays put for six seconds.
ktfmt, lint and 85 tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 12:15:44 -04:00
1 parent cab21442d6
commit 5eba6ec529
2 files changed
+85 -84

No files matched your search

+8 -8
View File
@@ -628,14 +628,14 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
a group's heading or the bar at its foot — is the end that stays put,
and the row grows away from it. The transcript is laid out from the
bottom, so a row's bottom edge is anchored for free and the top one
has to be arranged. `toggleAnchored` waits on the row's *size*, which
a scroll cannot change — a version that waited on the top edge instead
mistook the reader's own drag for the row resizing and undid it — and
then reads how far things moved off any row from the pressed one
upwards, since a group taller than the screen disappears under the
bottom edge as it shrinks and cannot report its own move (2026-08-30,
asked for after groups opened upwards and sent their own heading off
the top of the screen).
has to be arranged. The correction lives in the *layout* phase
(`Modifier.holdTopEdge`): the measurement that discovers the row's new
height asks the list to shift by that much, via
`requestScrollToItem`, before anything is drawn. Doing it from an
effect instead means the wrong position is drawn once first, which
reads as a flick and gets worse the faster the screen refreshes
(2026-08-30, asked for after groups opened upwards and sent their own
heading off the top of the screen).
- Input bar: text, attach (camera/gallery/file), send — **always enabled**;
mid-run sends become steering messages.
- Top bar: model chip (tap to change), stop button while running, token
@@ -5,7 +5,6 @@ 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.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -18,7 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -49,6 +47,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -63,10 +62,8 @@ import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private const val RECONNECT_DELAY_MS = 1500L
@@ -84,13 +81,50 @@ private const val RECONNECT_DELAY_MS = 1500L
private const val HISTORY_LOOKAHEAD = 8
/**
* How long to keep holding a row's top edge while the row settles to its new height.
* Which row was asked to hold its top edge, and how tall it was when it last measured.
*
* Long enough for the second and third layout passes a row can take to reach its final height, and
* short enough that it is over before the reader could have started scrolling for their own
* reasons. It always runs to the end: there is no way to ask whether more passes are coming.
* Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are
* written from the layout phase; a snapshot write there that composition reads would schedule
* another recomposition, and the correction has to land inside the frame that is already being laid
* out rather than in a later one. Nothing observes these, so nothing needs to.
*
* [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one.
*/
private const val ANCHOR_SETTLE_MS = 500L
private class TopEdgeHold {
var key: Any? = null
}
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
private class LastHeight {
var value: Int? = null
}
/**
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
*
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
* the whole reason it is a modifier rather than an effect. A correction posted to a coroutine
* arrives a frame or more after the layout it is correcting, so the wrong position is drawn once
* before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling
* from here happens before anything is drawn, so there is no frame to see and nothing that depends
* on how quickly the correction is scheduled.
*
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
* exactly that much is what leaves the top edge where it was.
*/
@Composable
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
val last = remember { LastHeight() }
return onSizeChanged { size ->
val previous = last.value
last.value = size.height
// A first measurement has no previous height to have moved from, and a row that came
// back after being scrolled away is a first measurement again.
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
held.key = null
hold(size.height - previous)
}
}
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
@@ -490,6 +524,7 @@ fun SessionScreen(
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says
@@ -692,72 +727,13 @@ fun SessionScreen(
* up off the screen and fills the space above it, so the calls appear on the far side of the
* control that produced them.
*
* So [RowEdge.Bottom] is the list's own behaviour and does nothing extra, and [RowEdge.Top]
* scrolls back by however much the row grew. It has to be measured rather than worked out: only
* the layout knows how tall an open group is, and it depends on the calls in it.
*
* What is measured is the row's *size*, and that is the whole reason this works. The obvious
* thing to measure is where its top edge went, and an earlier version did -- but a top edge
* also moves when the reader scrolls, so a correction still waiting for the layout would
* instead wake on the reader's own drag, read their scroll distance as the row's growth, and
* undo it. That was reported as the transcript jumping on every expand and refusing to scroll
* back at all. A size does not change when anybody scrolls, so the two cannot be confused.
*
* The row's bottom edge is held by the list, so scrolling by the growth is what puts the top
* edge back, and it is done for every step of that growth until [ANCHOR_SETTLE_MS] is up.
* So [RowEdge.Bottom] is the list's own behaviour and needs nothing, while [RowEdge.Top] marks
* the row as holding its top edge the next time it is measured. The correction itself belongs
* to the measurement -- see [holdTopEdge].
*/
fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) {
fun rowOf() = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
val row = rowOf()
if (edge == RowEdge.Top) topEdgeHeld.key = key
toggle()
// Not on screen when it was pressed, so there is no edge of it to hold.
if (edge == RowEdge.Bottom || row == null) return
// Read out now, as numbers: the layout hands back a reused object per slot, so holding
// one and reading it later describes whatever item took that slot since.
val rowIndex = row.index
val rowSize = row.size
// How far the row's top edge moved, read off any row that moved with it. The row and
// everything above it shift by exactly the row's growth, so they all give the same
// answer -- and several are recorded because which of them can answer varies. A row
// that shrinks slides down behind its own anchored bottom edge, and a group taller
// than the screen goes under the bottom of it entirely, leaving nothing of itself to
// measure.
fun edgeOf(item: LazyListItemInfo) =
if (item.index == rowIndex) item.offset + item.size else item.offset
val marks =
listState.layoutInfo.visibleItemsInfo
.filter { it.index >= rowIndex }
.associate { it.key to edgeOf(it) }
scope.launch {
// Waited on by *size*, which is the one thing a scroll cannot change. An earlier
// version waited on the top edge instead, so a correction still pending would wake
// on the reader's own drag, read their scroll distance as the row's growth and undo
// it -- reported as the transcript jumping on every expand and refusing to scroll
// back at all.
//
// The wait answers `true` rather than the size it saw, because the size it saw is
// sometimes `null` -- that is the row leaving the screen, which is a real answer and
// the one this has to handle. Returned straight out of `withTimeoutOrNull` it would
// be the same value that means "never happened", and the case needing the correction
// most was the case silently skipped.
val settled =
withTimeoutOrNull(ANCHOR_SETTLE_MS) {
snapshotFlow { rowOf()?.size }.first { it != rowSize }
true
}
if (settled == null) return@launch
// Never against a finger already on the screen: the reader is placing the list
// themselves, and that is also the only way a mark could have moved for a reason
// other than the row resizing.
if (listState.isScrollInProgress) return@launch
val moved =
listState.layoutInfo.visibleItemsInfo.firstNotNullOfOrNull { item ->
marks[item.key]?.let { edgeOf(item) - it }
} ?: return@launch
listState.scrollBy(moved.toFloat())
}
}
// A compaction reports nothing about its own progress -- measured against the CLI, which
@@ -1211,6 +1187,17 @@ fun SessionScreen(
// at the same end. Paging older history is the opposite insertion and was
// already fine, and stays fine, because a key survives both.
items(rows.asReversed(), key = { it.key }) { row ->
Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
// Requested rather than scrolled. Scrolling forces a remeasure,
// and forcing one from inside a measure throws; this is the form
// built to be asked for during layout and applied in that pass.
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset + grew,
)
}
) {
when (row) {
is TranscriptRow.Tools ->
ToolGroup(
@@ -1219,7 +1206,8 @@ fun SessionScreen(
onToggle = { edge ->
toggleAnchored(row.key, edge) {
expandedGroups =
if (row.id in expandedGroups) expandedGroups - row.id
if (row.id in expandedGroups)
expandedGroups - row.id
else expandedGroups + row.id
}
},
@@ -1236,7 +1224,12 @@ fun SessionScreen(
},
onAnswer = { questionId, answers ->
act {
answerQuestion(settings, summary.id, questionId, answers)
answerQuestion(
settings,
summary.id,
questionId,
answers,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
@@ -1273,12 +1266,19 @@ fun SessionScreen(
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
image = { ref ->
SessionImage(settings, summary.id, ref)
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answers ->
act {
answerQuestion(settings, summary.id, item.id, answers)
answerQuestion(
settings,
summary.id,
item.id,
answers,
)
}
}
is TranscriptItem.ErrorMsg ->
@@ -1315,6 +1315,7 @@ fun SessionScreen(
}
}
}
}
// Only while the newest message is off-screen. Reading back
// through a conversation is a place to be, not a state to be