From f4d4c82910c24189900cbbf63bd7de8323cc5d8c Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 02:52:45 -0400 Subject: [PATCH 1/5] Open a row downwards, from whichever end was pressed Tapping a group's heading used to send that heading up off the top of the screen and fill the space above it, so the calls appeared on the far side of the control that produced them. 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. The rule now is that the end the reader pressed is the end that must not move. A heading anchors the top, so the row opens downwards under it; the bar at the foot of an open group anchors the bottom, so shutting it from there leaves what follows the group where it is -- which is what already happened, but by accident of the layout rather than on purpose, and would have been lost the moment anything else changed. Bottom is the list's own behaviour and costs nothing. Top is measured rather than calculated: only the layout knows how tall an open group is, so `toggleAnchored` reads where the top edge was, lets the change land, and scrolls by however far it moved. Applied to every row that opens, not just groups -- a lone tool call and a peer message are the same gesture, and one of them opening the other way would be the odder for it. --- PLAN.md | 7 ++ .../kotlin/com/example/aiapp/SessionScreen.kt | 88 +++++++++++++++---- .../main/kotlin/com/example/aiapp/ToolRows.kt | 25 +++++- 3 files changed, 101 insertions(+), 19 deletions(-) diff --git a/PLAN.md b/PLAN.md index c95ef0a..f343c89 100644 --- a/PLAN.md +++ b/PLAN.md @@ -624,6 +624,13 @@ 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` measures the move and scrolls it + back (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 a3974c1..da78ce7 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 @@ -61,8 +62,11 @@ import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +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,14 @@ private const val RECONNECT_DELAY_MS = 1500L */ private const val HISTORY_LOOKAHEAD = 8 +/** + * How long to wait for a row to finish changing size before giving up on holding its top edge. + * + * Generous, because it is only reached when the answer never comes: the row is measured on the very + * next layout in every ordinary case. + */ +private const val ANCHOR_TIMEOUT_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 +681,41 @@ 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 used to send the + * heading up off the screen and fill the space above it, so the calls appeared 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] + * measures where the row's top edge was, lets the change land, and scrolls by however far it + * moved. 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. + * + * Given up on after [ANCHOR_TIMEOUT_MS] rather than waited on forever -- a row that never + * settles is one that is no longer on screen, and the reader has moved on. + */ + fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) { + fun topOf() = + listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == key } + ?.let { it.offset + it.size } + val before = topOf() + toggle() + if (edge == RowEdge.Bottom || before == null) return + scope.launch { + val after = + withTimeoutOrNull(ANCHOR_TIMEOUT_MS) { + snapshotFlow { topOf() }.filterNotNull().first { it != before } + } ?: return@launch + listState.scrollBy((after - before).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 +1172,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 +1212,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 +1259,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) } } } From ec40499ba42bc95d4b0b624ab5a9c85fe53e84e6 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 03:03:55 -0400 Subject: [PATCH 2/5] Revert "Open a row downwards, from whichever end was pressed" This reverts commit f4d4c82. The anchoring it added made the transcript jump on every expand and collapse, and left the list snapping back to the bottom when somebody scrolled up, which is worse than the upward-opening it was meant to fix. Two things to look at when this is retried. `LazyListItemInfo.offset` in a `reverseLayout` list is not obviously the coordinate space this assumed, so `offset + size` may have been measuring the bottom edge -- the one the list already holds -- rather than the top. And the anchoring scroll ran in a coroutine that could still be pending when the reader started dragging; `scrollBy` takes the default mutation priority, so it cancels that drag. Verify the next attempt with `uiautomator dump` -- node bounds in device pixels, before and after a toggle -- rather than by eye from screenshots. Co-Authored-By: Claude Opus 5 --- PLAN.md | 7 -- .../kotlin/com/example/aiapp/SessionScreen.kt | 88 ++++--------------- .../main/kotlin/com/example/aiapp/ToolRows.kt | 25 +----- 3 files changed, 19 insertions(+), 101 deletions(-) diff --git a/PLAN.md b/PLAN.md index d81d991..b4fbf1e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -624,13 +624,6 @@ 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` measures the move and scrolls it - back (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 fe09e52..5a1c4c0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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 @@ -62,11 +61,8 @@ import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull private const val RECONNECT_DELAY_MS = 1500L @@ -83,14 +79,6 @@ private const val RECONNECT_DELAY_MS = 1500L */ private const val HISTORY_LOOKAHEAD = 8 -/** - * How long to wait for a row to finish changing size before giving up on holding its top edge. - * - * Generous, because it is only reached when the answer never comes: the row is measured on the very - * next layout in every ordinary case. - */ -private const val ANCHOR_TIMEOUT_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 @@ -681,41 +669,6 @@ 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 used to send the - * heading up off the screen and fill the space above it, so the calls appeared 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] - * measures where the row's top edge was, lets the change land, and scrolls by however far it - * moved. 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. - * - * Given up on after [ANCHOR_TIMEOUT_MS] rather than waited on forever -- a row that never - * settles is one that is no longer on screen, and the reader has moved on. - */ - fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) { - fun topOf() = - listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.key == key } - ?.let { it.offset + it.size } - val before = topOf() - toggle() - if (edge == RowEdge.Bottom || before == null) return - scope.launch { - val after = - withTimeoutOrNull(ANCHOR_TIMEOUT_MS) { - snapshotFlow { topOf() }.filterNotNull().first { it != before } - } ?: return@launch - listState.scrollBy((after - before).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 @@ -1172,23 +1125,16 @@ fun SessionScreen( ToolGroup( group = row, expanded = row.id in expandedGroups, - onToggle = { edge -> - toggleAnchored(row.key, edge) { - expandedGroups = - if (row.id in expandedGroups) expandedGroups - row.id - else expandedGroups + row.id - } + onToggle = { + 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 -> - toggleAnchored(row.key, RowEdge.Top) { - expandedTools = - if (id in expandedTools) expandedTools - id - else expandedTools + id - } + expandedTools = + if (id in expandedTools) expandedTools - id + else expandedTools + id }, onAnswer = { questionId, answers -> act { @@ -1212,12 +1158,10 @@ fun SessionScreen( tool = item, expanded = item.id in expandedTools, onToggle = { - toggleAnchored(row.key, RowEdge.Top) { - expandedTools = - if (item.id in expandedTools) - expandedTools - item.id - else expandedTools + item.id - } + expandedTools = + if (item.id in expandedTools) + expandedTools - item.id + else expandedTools + item.id }, onAnswer = { questionId, answers -> act { @@ -1259,12 +1203,10 @@ fun SessionScreen( item = item, expanded = item.seq in expandedNotes, onToggle = { - toggleAnchored(row.key, RowEdge.Top) { - expandedNotes = - if (item.seq in expandedNotes) - expandedNotes - item.seq - else expandedNotes + item.seq - } + 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 d7bae76..4d53dd0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -99,20 +99,6 @@ 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. * @@ -127,15 +113,14 @@ enum class RowEdge { fun ToolGroup( group: TranscriptRow.Tools, expanded: Boolean, - /** Told which end was pressed, because this row has a control at each -- see [RowEdge]. */ - onToggle: (RowEdge) -> Unit, + onToggle: () -> Unit, isToolExpanded: (String) -> Boolean, onToolToggle: (String) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit, ) { if (!expanded) { - Card(Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }) { + Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, @@ -148,7 +133,7 @@ fun ToolGroup( Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }.padding(12.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(12.dp), ) group.calls.forEach { call -> ToolCard( @@ -159,9 +144,7 @@ fun ToolGroup( image = image, ) } - // 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) } + CollapseBar(onToggle) } } 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 3/5] 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) } } } From 5eba6ec529f8dc6571da4b1fc5b8fad9b04571e8 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 12:15:44 -0400 Subject: [PATCH 4/5] 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 --- PLAN.md | 16 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 329 +++++++++--------- 2 files changed, 173 insertions(+), 172 deletions(-) diff --git a/PLAN.md b/PLAN.md index e9f126b..2e27f13 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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 0f63906..a5bc392 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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()) } 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,107 +1187,132 @@ 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 -> - when (row) { - is TranscriptRow.Tools -> - ToolGroup( - group = row, - expanded = row.id in expandedGroups, - 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 -> - toggleAnchored(row.key, RowEdge.Top) { - expandedTools = - if (id in expandedTools) expandedTools - id - else expandedTools + id - } - }, - onAnswer = { questionId, answers -> - act { - answerQuestion(settings, summary.id, questionId, answers) - } - }, - image = { ref -> SessionImage(settings, summary.id, ref) }, + 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, ) - is TranscriptRow.Single -> - when (val item = row.item) { - is TranscriptItem.UserMsg -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = item.text, - images = item.images, - ) - is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, - onToggle = { - toggleAnchored(row.key, RowEdge.Top) { - expandedTools = - if (item.id in expandedTools) - expandedTools - item.id - else expandedTools + item.id - } - }, - onAnswer = { questionId, answers -> + } + ) { + when (row) { + is TranscriptRow.Tools -> + ToolGroup( + group = row, + expanded = row.id in expandedGroups, + 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 -> + toggleAnchored(row.key, RowEdge.Top) { + expandedTools = + if (id in expandedTools) expandedTools - id + else expandedTools + id + } + }, + onAnswer = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> SessionImage(settings, summary.id, ref) }, + ) + is TranscriptRow.Single -> + when (val item = row.item) { + is TranscriptItem.UserMsg -> + UserBubble( + settings = settings, + sessionId = summary.id, + text = item.text, + images = item.images, + ) + is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { + toggleAnchored(row.key, RowEdge.Top) { + expandedTools = + if (item.id in expandedTools) + expandedTools - item.id + else expandedTools + item.id + } + }, + onAnswer = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answers -> act { answerQuestion( settings, summary.id, - questionId, + item.id, answers, ) } - }, - image = { ref -> SessionImage(settings, summary.id, ref) }, - ) - is TranscriptItem.QuestionCard -> - QuestionRow(item) { answers -> - act { - answerQuestion(settings, summary.id, item.id, answers) } - } - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> - SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - is TranscriptItem.CommandRow -> CommandBubble(item.text) - is TranscriptItem.ClearedNote -> ClearedRow() - is TranscriptItem.CompactedNote -> CompactedRow(item) - is TranscriptItem.PeerNote -> - PeerMessageRow( - item = item, - expanded = item.seq in expandedNotes, - onToggle = { - toggleAnchored(row.key, RowEdge.Top) { - expandedNotes = - if (item.seq in expandedNotes) - expandedNotes - item.seq - else expandedNotes + item.seq - } - }, - ) - } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> + SessionImage(settings, summary.id, item.ref) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + is TranscriptItem.CommandRow -> CommandBubble(item.text) + is TranscriptItem.ClearedNote -> ClearedRow() + is TranscriptItem.CompactedNote -> CompactedRow(item) + is TranscriptItem.PeerNote -> + PeerMessageRow( + item = item, + expanded = item.seq in expandedNotes, + onToggle = { + toggleAnchored(row.key, RowEdge.Top) { + expandedNotes = + if (item.seq in expandedNotes) + expandedNotes - item.seq + else expandedNotes + item.seq + } + }, + ) + } + } } } } From 30ebf4e25caf826df5a3785cfd2b07a2bbfe3e4e Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 12:29:41 -0400 Subject: [PATCH 5/5] Keep still the end of a row nearest the tap, not the control pressed Everything that opens now behaves alike. Touch a row's upper half and its top edge holds, so it opens and closes downwards; touch the lower half and the bottom edge holds, which is what the list does on its own. A group's heading and the bar at its foot fall in the halves they already occupy, so they keep the behaviour they had, and a single tool call -- one card, with no bar -- gets the same choice for the first time: tapping low on an open Bash card now shuts it downwards exactly as a group's bar does. That makes the position of the tap the one mechanism, and RowEdge goes away with the pair of hardcoded ends it existed to name. Controls report where they were touched in root coordinates, which is all a control can know -- a group is one row with a control at each end and calls in the middle, and only the row knows where its own ends are -- and the row turns that into an edge. `clickableAt` is built on `clickable` rather than replacing it, so the ripple and the click action assistive technology reads are unchanged; the down position is observed on the initial pointer pass and nothing is consumed. Verified with ui-trace: on a collapsed group, a tap at y=1370 holds the heading and one at y=1450 lets the row grow upward instead. On the same nested call inside an open group, opening it from the group's upper half holds the heading at 565 and from the lower half moves it to 296. ktfmt, lint and 85 tests clean. Co-Authored-By: Claude Opus 5 --- PLAN.md | 9 +- .../kotlin/com/example/aiapp/PeerMessage.kt | 5 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 82 ++++++++++++------- .../main/kotlin/com/example/aiapp/ToolRows.kt | 66 ++++++++++----- 4 files changed, 108 insertions(+), 54 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2e27f13..2817db8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -624,9 +624,12 @@ 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 + - Expanding a row keeps still **the end nearest the tap**: touch a row's + upper half and its top edge holds, so it opens downwards; touch its + lower half and the bottom edge holds, as the list does by default. + Which half, rather than which control, so that everything that opens + behaves alike whether or not it has a control at each end — a group's + heading and foot bar simply fall in the halves they already occupy. 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. The correction lives in the *layout* phase (`Modifier.holdTopEdge`): the measurement that discovers the row's new 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 ff4ad7a..e2b3ef3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -1,6 +1,5 @@ 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 @@ -32,10 +31,10 @@ import androidx.compose.ui.unit.dp fun PeerMessageRow( item: TranscriptItem.PeerNote, expanded: Boolean, - onToggle: () -> Unit, + onToggle: (Float) -> Unit, modifier: Modifier = Modifier, ) { - Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(modifier.fillMaxWidth().clickableAt(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 a5bc392..1a21078 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -47,7 +47,9 @@ 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.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 @@ -94,6 +96,21 @@ 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 @@ -718,21 +735,25 @@ fun SessionScreen( } /** - * Changes a row's height while the end the reader pressed stays where it is. + * Changes a row's height while the end the reader touched 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. + * holds still and all growth goes upward. That is what a tap in a row's lower half already + * gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not + * move, which is what the reader is looking at down there. A tap in the upper half is the other + * case -- left alone it sends the heading under the reader's finger up off the screen and fills + * the space above it, so the calls appear on the far side of the control that produced them -- + * and that one asks the row to hold its top edge instead. * - * 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]. + * Which half decides it, rather than which control was pressed, so that everything that opens + * behaves the same way whether or not it happens to have a control at each end. A group has two + * and its heading and foot bar land in the halves they are already in; a single call is one + * card, and tapping low on an open one shuts it downward exactly as the bar does. + * + * The correction itself belongs to the measurement -- see [holdTopEdge]. */ - fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) { - if (edge == RowEdge.Top) topEdgeHeld.key = key + fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) { + if (at < row.middle) topEdgeHeld.key = key toggle() } @@ -1187,24 +1208,29 @@ 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 -> + val bounds = remember { RowBounds() } 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, - ) - } + Modifier.onGloballyPositioned { + bounds.top = it.positionInRoot().y + bounds.height = it.size.height.toFloat() + } + .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( group = row, expanded = row.id in expandedGroups, - onToggle = { edge -> - toggleAnchored(row.key, edge) { + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { expandedGroups = if (row.id in expandedGroups) expandedGroups - row.id @@ -1215,8 +1241,8 @@ fun SessionScreen( // 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 -> - toggleAnchored(row.key, RowEdge.Top) { + onToolToggle = { id, at -> + toggleAnchored(row.key, bounds, at) { expandedTools = if (id in expandedTools) expandedTools - id else expandedTools + id @@ -1248,8 +1274,8 @@ fun SessionScreen( ToolCard( tool = item, expanded = item.id in expandedTools, - onToggle = { - toggleAnchored(row.key, RowEdge.Top) { + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { expandedTools = if (item.id in expandedTools) expandedTools - item.id @@ -1302,8 +1328,8 @@ fun SessionScreen( PeerMessageRow( item = item, expanded = item.seq in expandedNotes, - onToggle = { - toggleAnchored(row.key, RowEdge.Top) { + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { 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 d7bae76..d8a946e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -2,6 +2,8 @@ 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 @@ -18,6 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -99,18 +105,36 @@ 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 +} + /** - * Which end of a row a reader acted on, and therefore which end must not move. + * Clickable, and tells the click where on the screen the finger went down. * - * 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. + * 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. */ -enum class RowEdge { - Top, - Bottom, +@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) } } /** @@ -127,15 +151,17 @@ enum class RowEdge { fun ToolGroup( group: TranscriptRow.Tools, expanded: Boolean, - /** Told which end was pressed, because this row has a control at each -- see [RowEdge]. */ - onToggle: (RowEdge) -> Unit, + /** + * Told where it was pressed, because this row has a control at each end -- see [clickableAt]. + */ + onToggle: (Float) -> Unit, isToolExpanded: (String) -> Boolean, - onToolToggle: (String) -> Unit, + onToolToggle: (String, Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit, ) { if (!expanded) { - Card(Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, @@ -148,30 +174,30 @@ fun ToolGroup( Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }.padding(12.dp), + modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp), ) group.calls.forEach { call -> ToolCard( tool = call, expanded = isToolExpanded(call.id), - onToggle = { onToolToggle(call.id) }, + onToggle = { at -> onToolToggle(call.id, at) }, onAnswer = onAnswer, image = image, ) } // 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) } + CollapseBar(onToggle) } } /** The bottom half of a group's toggle: an arrow back up to its heading. */ @Composable -private fun CollapseBar(onToggle: () -> Unit) { +private fun CollapseBar(onToggle: (Float) -> Unit) { val colour = MaterialTheme.colorScheme.onSurfaceVariant Row( Modifier.fillMaxWidth() - .clickable(onClick = onToggle) + .clickableAt(onToggle) .semantics { contentDescription = "Collapse these tool calls" } .padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center, @@ -198,14 +224,14 @@ private fun CollapseBar(onToggle: () -> Unit) { fun ToolCard( tool: TranscriptItem.ToolRun, expanded: Boolean, - onToggle: () -> Unit, + onToggle: (Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit = {}, ) { 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().clickable(onClick = onToggle)) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(tool.tool, style = MaterialTheme.typography.titleSmall)