diff --git a/PLAN.md b/PLAN.md index f2141d8..3f57a8c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1189,25 +1189,28 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). question *was*, and "Deny" alone does not say Allow was the alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer typed into **Other** matches no option, so that one is still written out. - - **Opening a row keeps still the end nearest the tap; closing one lands - the closed row centred on the tap itself; and a call opened or shut - *inside* a group keeps the group's top edge** (2026-09-16). A row about - to open is small, so both its edges are within a heading's height of the - finger and the wanted one is the edge pressed: touch the upper half and - the top edge holds, so it opens downwards; touch the lower half and the - bottom edge holds, as the list does by default. A row about to close - leaves a heading where a screenful of card was, and the place that - heading belongs is under the finger that shut it — a card taller than the - screen settles it, since both of its edges can be a screen's length from - the hand, and holding a *share* of the card's height was the same miss in - miniature: tap away from the middle of a long card and the heading landed - a card's height off. The third case is the one where the row is not the - thing being opened at all but the container of it; what the reader is - looking at is the call under their finger, and holding the group's top - edge is what keeps everything above the change — that call's own heading - included — exactly where it is. Centring the group instead threw a group - of six the length of the screen. Where there is not enough conversation - on the far side to scroll, the row lands as close as the list can put it. + - **Opening keeps still the edge nearest the tap; closing lands the closed + thing centred on the tap itself** (2026-09-16), and the *thing* is + whatever the reader pressed: the row, or the one call inside an open + group. A row about to open is small, so both its edges are within a + heading's height of the finger and the wanted one is the edge pressed: + touch the upper half and the top edge holds, so it opens downwards; + touch the lower half and the bottom edge holds, as the list does by + default. A row about to close leaves a heading where a screenful of card + was, and the place that heading belongs is under the finger that shut it + — a card taller than the screen settles it, since both of its edges can + be a screen's length from the hand. Holding a *share* of the card's + height was the same miss in miniature, and holding the group's top edge + when a call inside it was shut was the same miss again: the card + appeared to collapse into its own top, a long way from the hand. Where + there is not enough conversation on the far side to scroll, it lands as + close as the list can put it. + A call is not a row, so the scroll is asked for against the group and + aimed at the call: `ToolGroup` reports how far down its own top edge the + call is drawn and how tall that card is (`onToolToggle`), which is the + part only it knows, and the calls above the one toggled do not move, so + shifting the group by the difference puts the call where the finger + wants it. **The scroll is asked for in the gesture, not from the layout that discovers the new height**, and that is what makes it invisible: a request made at the tap is consumed by the same measure pass that first @@ -1216,14 +1219,15 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). from the placement phase is never picked up by another measure and does nothing at all; one from the measure phase lands on the *next* frame, with the uncorrected one drawn first; and correcting by the error each - pass can see is that again, once per pass. None of the three positions - needs the new height, which is what lets them be asked for before it + pass can see is that again, once per pass. Nothing it asks for needs the + new height, which is what lets it be asked for before that height exists: a top edge holds by placing the item *above* the row where it already is, since that item's bottom edge is the row's top edge whatever becomes of the row, and it does not have to be composed for the list to - place it. A close places the row itself, against the height it had at the - moment it was opened — what the reader is shutting is the card they - opened, so that is the height it is going back to (`closedHeights`). + place it. A close places the row against the height it had at the moment + it was opened — what the reader is shutting is the card they opened, so + that is the height it is going back to (`closedHeights`, + `closedCallHeights`). - **The full-screen image lives on the screen, not in the row that drew the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image is a row of one call until the next call arrives and makes it a group — a 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 fe427c2..ee26e09 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -216,6 +216,10 @@ fun SessionScreen( // the reader shuts it -- and the one thing a correction made in the gesture cannot look up. // Only rows the reader has opened are in it, so it is as small as the screen's open set. val closedHeights = remember { mutableMapOf() } + // The same for calls drawn inside a group, which are not rows and so are not keyed like them -- + // a run takes its name from its first call, so one id would otherwise stand for both the group + // and the call it is named after, whose closed heights are different things. + val closedCallHeights = remember { mutableMapOf() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) } var backgroundTasks by @@ -558,13 +562,9 @@ fun SessionScreen( * they pressed -- so the touch picks the nearer of the two rather than carrying the heading * part of the way up an unbounded expansion. * - * [inner] is neither, and is what a call inside an open group is: the row is not the thing - * being opened or shut, it is the container of it, and it stays a container either way. What - * the reader is looking at is the call under their finger, and what keeps it there is holding - * the row's *top* edge -- everything above the change keeps its place, so the call's own - * heading does too, and the rest of the group moves below it. Centring the group on the tap - * instead, which is what it did for a day, threw a group of six the length of the screen for a - * call closed inside it. + * A call *inside* an open group is the same rule applied one level down, and needs saying + * separately because the row is then the group rather than the thing being opened: see + * [toggleCallAnchored]. * * Where the touch landed decides the rest, rather than which control was pressed, so everything * behaves the same way whether or not it has a control at each end. @@ -575,18 +575,13 @@ fun SessionScreen( * is drawn once, in its right place. Made from the layout instead it is a *frame* late, and the * frame before it -- the row resized and nothing corrected -- is drawn: opening a card flicked. * - * None of the three positions needs to know the new height, which is what lets them be asked - * for before it exists. A top edge holds by placing the item *above* the row where it already - * is, since that item's bottom edge is the row's top edge whatever happens to the row. A close - * places the row itself, using the height it had at the moment it was opened: what the reader - * is shutting is the card they opened, so that is the height it is going back to. + * Neither position needs to know the new height, which is what lets them be asked for before it + * exists. A top edge holds by placing the item *above* the row where it already is, since that + * item's bottom edge is the row's top edge whatever happens to the row. A close places the row + * itself, using the height it had at the moment it was opened: what the reader is shutting is + * the card they opened, so that is the height it is going back to. */ - fun toggleAnchored( - row: TranscriptRow, - closing: Boolean, - inner: Boolean = false, - toggle: () -> Unit, - ) = expanding { + fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding { val info = listState.layoutInfo val item = info.visibleItemsInfo.firstOrNull { it.key == row.key } if (lastTouch.key == row.key && item != null) { @@ -598,21 +593,56 @@ fun SessionScreen( val closed = if (closing) closedHeights[row.key] else null when { // The card the reader is shutting, centred on the finger that shut it. - closing && !inner && closed != null -> - listState.requestScrollToItem(item.index, closed / 2 - touch) - // Everything above the change stays where it is: the row's top edge, which is the - // bottom edge of the item above it. That item does not have to be composed for the - // list to place it, which matters -- the top of a group taller than the screen is - // exactly where nothing above it is. - (inner || lastTouch.y < lastTouch.height / 2) && - item.index + 1 < info.totalItemsCount -> + closed != null -> listState.requestScrollToItem(item.index, closed / 2 - touch) + // An open in the row's top half holds that edge: everything above the row stays + // where it is, which is the same as saying the item above it does. + lastTouch.y < lastTouch.height / 2 && item.index + 1 < info.totalItemsCount -> listState.requestScrollToItem(item.index + 1, -above) // What is left is an open in the row's lower half, which wants its bottom edge // held, and that is what the list does on its own. else -> {} } // Its height as it stands is the height it goes back to when it is shut again. - if (!closing && !inner) closedHeights[row.key] = lastTouch.height + if (!closing) closedHeights[row.key] = lastTouch.height + } + toggle() + } + + /** + * The same, for a call inside an open group: the call lands centred on the finger that shut it, + * and holds its own top edge when it is opened. + * + * The group is what the list knows -- a call is drawn inside a row rather than being one -- so + * the group is what the scroll is asked for, and [top] and [height], which only the group can + * report, are what turn the call's place inside it into a place on the screen. Calls above the + * one toggled do not move, so the call's own top stays [top] below the group's, and shifting + * the group by the difference puts the call where the finger wants it. + * + * Holding the group's top edge is what an open wants and is what a close used to get as well. + * It is right for the open -- the call's heading is the edge under the finger -- and wrong for + * the close by however far down the open call the reader pressed, which for a card of output is + * most of the screen: the card appeared to shrink to its own top, a long way from the hand. + */ + fun toggleCallAnchored( + row: TranscriptRow, + id: String, + top: Int, + height: Int, + toggle: () -> Unit, + ) = expanding { + val info = listState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.key == row.key } + if (lastTouch.key == row.key && item != null && item.index + 1 < info.totalItemsCount) { + val opening = id !in expandedTools + val closed = if (opening) null else closedCallHeights[id] + val touch = item.offset + lastTouch.height - lastTouch.y + // Where the call's top edge is now, and where it has to be for the closed call to sit + // centred on the finger; the group's top moves by the difference between them. + val callTop = item.offset + lastTouch.height - top + val shift = if (closed == null) 0 else touch + closed / 2 - callTop + listState.requestScrollToItem(item.index + 1, -(item.offset + item.size + shift)) + // Its height as it stands is the height it goes back to when it is shut again. + if (opening) closedCallHeights[id] = height } toggle() } @@ -1657,15 +1687,11 @@ fun SessionScreen( } }, 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, - closing = id in expandedTools, - inner = true, - ) { + // The scroll is asked for against the group, since + // that is the row the list knows, and aimed at the + // call -- see [toggleCallAnchored]. + onToolToggle = { id, top, height -> + toggleCallAnchored(row, id, top, height) { expandedTools = if (id in expandedTools) expandedTools - id 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 b43ea6f..105ba63 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -196,7 +199,15 @@ fun ToolGroup( */ onToggle: () -> Unit, isToolExpanded: (String) -> Boolean, - onToolToggle: (String) -> Unit, + /** + * Toggles one call, and says where in the group it was drawn: how far down the group's own top + * edge its card begins, and how tall that card is now. + * + * The screen anchors on *rows*, and a call is not one -- but what the reader is opening or + * shutting is the call, and keeping it under their finger needs its place inside the row. Only + * the group knows that, so only the group can say it. See `SessionScreen`'s `toggleAnchored`. + */ + onToolToggle: (id: String, top: Int, height: Int) -> Unit, onAnswer: (List, onSettled: () -> Unit) -> Unit, image: @Composable (String) -> Unit, ) { @@ -211,8 +222,10 @@ fun ToolGroup( } return } + val placed = remember { Placed() } Column( Modifier.fillMaxWidth() + .onPlaced { placed.top = it.positionInRoot().y } .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceContainerLow) ) { @@ -232,13 +245,19 @@ fun ToolGroup( verticalArrangement = Arrangement.spacedBy(GROUP_GAP), ) { group.calls.forEachIndexed { index, call -> + val card = remember(call.id) { Placed() } ToolCard( tool = call, expanded = isToolExpanded(call.id), - onToggle = { onToolToggle(call.id) }, + onToggle = { + onToolToggle(call.id, (card.top - placed.top).toInt(), card.height) + }, onAnswer = onAnswer, image = image, shape = connectedShape(index, group.calls.size), + modifier = + Modifier.onPlaced { card.top = it.positionInRoot().y } + .onSizeChanged { card.height = it.height }, ) } } @@ -248,6 +267,18 @@ fun ToolGroup( } } +/** + * Where something was last placed, in the window's coordinates, and how tall it was. + * + * Deliberately not snapshot state: it is written from the layout phase, and a write there that + * composition reads would schedule another recomposition of every group on screen, every frame. + * Nothing reads it except the gesture that follows. + */ +private class Placed { + var top = 0f + var height = 0 +} + /** * The height of a group's heading, and so of the bar at its foot. * @@ -329,13 +360,14 @@ fun ToolCard( image: @Composable (String) -> Unit = {}, /** Square where this card faces another in a group; see [connectedShape]. */ shape: Shape = CardDefaults.shape, + modifier: Modifier = Modifier, ) { val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val name = toolDisplayName(tool.tool) val output = toolDisplayOutput(tool.tool, tool.output) val deciding = tool.asks.any { it.answers.isEmpty() } val open = expanded || deciding - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { + Card(modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { Column(Modifier.padding(GROUP_INSET_LARGE)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(name, style = MaterialTheme.typography.titleSmall)