Open a row downwards, and hold the edge that was pressed

Reinstates the reverted downward-opening rows with the two defects that
made the first attempt worse than what it replaced.

The correction waited on the row's top edge and could wait up to half a
second for it to move. A top edge also moves when the reader scrolls, so a
correction still pending would wake on their drag, read the scroll distance
as the row's growth, and undo it -- the transcript jumping on every expand
and refusing to scroll back at all. It now waits on the row's *size*, which
nothing but a resize changes.

The second is why collapsing a group taller than the screen did nothing at
all. Such a group is the list's own anchor item, so as it shrinks it slides
down behind its anchored bottom edge and out of the viewport, and its size
reads as null -- which `withTimeoutOrNull` cannot tell from the null that
means the wait expired. The case most needing the correction was the one
silently skipped. The wait now answers a value that a timeout cannot, and
the distance is read off any row from the pressed one upwards, all of which
move by exactly the row's growth.

Verified on the emulator with ui-trace (~/.local/bin), which samples the
accessibility tree at 60Hz and reports node bounds in device pixels:
expanding and collapsing from a heading hold it to the pixel, collapsing
from the foot bar holds all 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 11:56:53 -04:00
1 parent ec40499ba4
commit cab21442d6
3 files changed
+150 -19

No files matched your search

@@ -5,6 +5,7 @@ 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
@@ -17,6 +18,7 @@ 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
@@ -61,8 +63,10 @@ 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
@@ -79,6 +83,15 @@ 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.
*
* 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.
*/
private const val ANCHOR_SETTLE_MS = 500L
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
@@ -669,6 +682,84 @@ fun SessionScreen(
}
}
/**
* Changes a row's height while the end the reader pressed stays where it is.
*
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
* holds still and all growth goes upward. That is right for the bar at the foot of an open
* group -- shut it from there and what follows it does not move, which is what the reader is
* looking at. It is exactly wrong for a heading: opening a group from the top sends the heading
* 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.
*/
fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) {
fun rowOf() = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
val row = rowOf()
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
// says it has started, and then says nothing at all until it is done. So what this counts is
// the one thing anybody here can measure: how long it has been going. A bar filling up would
@@ -1125,16 +1216,23 @@ fun SessionScreen(
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = {
expandedGroups =
if (row.id in expandedGroups) expandedGroups - row.id
else expandedGroups + row.id
onToggle = { edge ->
toggleAnchored(row.key, edge) {
expandedGroups =
if (row.id in expandedGroups) expandedGroups - row.id
else expandedGroups + row.id
}
},
isToolExpanded = { it in expandedTools },
// Anchored on the group, not the call: opening one call makes
// the whole group taller, and the heading the reader is under
// is the group's.
onToolToggle = { id ->
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
toggleAnchored(row.key, RowEdge.Top) {
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
}
},
onAnswer = { questionId, answers ->
act {
@@ -1158,10 +1256,12 @@ fun SessionScreen(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
toggleAnchored(row.key, RowEdge.Top) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
}
},
onAnswer = { questionId, answers ->
act {
@@ -1203,10 +1303,12 @@ fun SessionScreen(
item = item,
expanded = item.seq in expandedNotes,
onToggle = {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
toggleAnchored(row.key, RowEdge.Top) {
expandedNotes =
if (item.seq in expandedNotes)
expandedNotes - item.seq
else expandedNotes + item.seq
}
},
)
}