Centre a closed call on the tap, not the group around it

A call inside an open group was the one case still anchored by an edge: the
group held its top, which is right when a call is opened -- the heading under
the finger is the edge being pressed -- and wrong when one is shut by however
far down the open card the reader pressed. On a card of output that is most of
the screen, and what it looks like is the card collapsing into its own top, a
long way from the hand. It is the same rule as every other close now: what is
left of the call lands centred on the finger that shut it.

A call is not a row, so the scroll is still asked for against the group and
merely aimed at the call. What makes that possible is the group reporting how
far down its own top edge the call is drawn and how tall that card is, which is
the part only it knows; the calls above the one toggled do not move, so shifting
the group by the difference puts the call where the finger wants it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against a real imported conversation: a call opened from its
heading inside a group of twelve leaves that heading where it is, and closing it
again from the middle of its output at 1800 lands the closed call at 1738..1860
-- centred on the tap to the pixel. The group's own close still centres on its
heading (1287..1413 for a tap at 1350).
This commit is contained in:
iris-ai committed 2026-09-16 03:22:55 -04:00
1 parent 914985b8b2
commit ee5bef3686
3 files changed
+125 -63

No files matched your search

@@ -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<Any, Int>() }
// 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<String, Int>() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
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
@@ -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<QuestionAnswer>, 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)