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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 12:29:41 -04:00
1 parent 5eba6ec529
commit 30ebf4e25c
4 files changed
+108 -54

No files matched your search

@@ -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<TranscriptItem>): List<TranscriptRow> {
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<String>) -> 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<String>) -> 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)