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] 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)