From cab21442d6487c87d3ee4accbfd07f29a3a7d386 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 11:56:53 -0400 Subject: [PATCH] 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 --- PLAN.md | 12 ++ .../kotlin/com/example/aiapp/SessionScreen.kt | 132 ++++++++++++++++-- .../main/kotlin/com/example/aiapp/ToolRows.kt | 25 +++- 3 files changed, 150 insertions(+), 19 deletions(-) diff --git a/PLAN.md b/PLAN.md index b4fbf1e..e9f126b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -624,6 +624,18 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: output; a spinner while `ToolStart` has no matching `ToolEnd`). - Question cards inline: option buttons for AskUserQuestion, allow/deny for permissions, free-text where allowed. + - Expanding a row **opens downwards**: whichever end the reader pressed — + 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). - 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 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 5a1c4c0..0f63906 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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 + } }, ) } 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 4d53dd0..d7bae76 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -99,6 +99,20 @@ fun groupToolRuns(items: List): List { return rows } +/** + * Which end of a row a reader acted on, and therefore which end must not move. + * + * A row has two controls at opposite ends -- the heading that opens it and the bar that shuts it + * again -- and the reader's finger is on one of them. Whichever it is has to stay where it is while + * the row changes size, or the thing they just pressed slides out from under them. The list anchors + * every row's bottom edge by default (see the transcript's `reverseLayout`), so [Bottom] is what + * happens on its own and [Top] is what has to be arranged. + */ +enum class RowEdge { + Top, + Bottom, +} + /** * Several calls under one heading, closed until somebody asks. * @@ -113,14 +127,15 @@ fun groupToolRuns(items: List): List { fun ToolGroup( group: TranscriptRow.Tools, expanded: Boolean, - onToggle: () -> Unit, + /** Told which end was pressed, because this row has a control at each -- see [RowEdge]. */ + onToggle: (RowEdge) -> Unit, isToolExpanded: (String) -> Boolean, onToolToggle: (String) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit, ) { if (!expanded) { - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }) { Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, @@ -133,7 +148,7 @@ fun ToolGroup( Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(12.dp), + modifier = Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }.padding(12.dp), ) group.calls.forEach { call -> ToolCard( @@ -144,7 +159,9 @@ fun ToolGroup( image = image, ) } - CollapseBar(onToggle) + // Shutting it from here anchors the other end: the reader is at the bottom of a long + // group, and what they are looking at is what follows it. + CollapseBar { onToggle(RowEdge.Bottom) } } }