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

+28 -24
View File
@@ -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. question *was*, and "Deny" alone does not say Allow was the alternative.
One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer
typed into **Other** matches no option, so that one is still written out. 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 - **Opening keeps still the edge nearest the tap; closing lands the closed
the closed row centred on the tap itself; and a call opened or shut thing centred on the tap itself** (2026-09-16), and the *thing* is
*inside* a group keeps the group's top edge** (2026-09-16). A row about whatever the reader pressed: the row, or the one call inside an open
to open is small, so both its edges are within a heading's height of the group. A row about to open is small, so both its edges are within a
finger and the wanted one is the edge pressed: touch the upper half and heading's height of the finger and the wanted one is the edge pressed:
the top edge holds, so it opens downwards; touch the lower half and the touch the upper half and the top edge holds, so it opens downwards;
bottom edge holds, as the list does by default. A row about to close touch the lower half and the bottom edge holds, as the list does by
leaves a heading where a screenful of card was, and the place that default. A row about to close leaves a heading where a screenful of card
heading belongs is under the finger that shut it — a card taller than the was, and the place that heading belongs is under the finger that shut it
screen settles it, since both of its edges can be a screen's length from — a card taller than the screen settles it, since both of its edges can
the hand, and holding a *share* of the card's height was the same miss in be a screen's length from the hand. Holding a *share* of the card's
miniature: tap away from the middle of a long card and the heading landed height was the same miss in miniature, and holding the group's top edge
a card's height off. The third case is the one where the row is not the when a call inside it was shut was the same miss again: the card
thing being opened at all but the container of it; what the reader is appeared to collapse into its own top, a long way from the hand. Where
looking at is the call under their finger, and holding the group's top there is not enough conversation on the far side to scroll, it lands as
edge is what keeps everything above the change — that call's own heading close as the list can put it.
included — exactly where it is. Centring the group instead threw a group A call is not a row, so the scroll is asked for against the group and
of six the length of the screen. Where there is not enough conversation aimed at the call: `ToolGroup` reports how far down its own top edge the
on the far side to scroll, the row lands as close as the list can put it. 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 **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 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 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 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, 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 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 pass can see is that again, once per pass. Nothing it asks for needs the
needs the new height, which is what lets them be asked for before it 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 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 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 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 place it. A close places the row against the height it had at the moment
moment it was opened — what the reader is shutting is the card they it was opened — what the reader is shutting is the card they opened, so
opened, so that is the height it is going back to (`closedHeights`). 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 full-screen image lives on the screen, not in the row that drew
the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image 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 is a row of one call until the next call arrives and makes it a group — a
@@ -216,6 +216,10 @@ fun SessionScreen(
// the reader shuts it -- and the one thing a correction made in the gesture cannot look up. // 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. // 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>() } 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 items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(subagent?.status ?: summary.status) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
var backgroundTasks by 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 * they pressed -- so the touch picks the nearer of the two rather than carrying the heading
* part of the way up an unbounded expansion. * 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 * A call *inside* an open group is the same rule applied one level down, and needs saying
* being opened or shut, it is the container of it, and it stays a container either way. What * separately because the row is then the group rather than the thing being opened: see
* the reader is looking at is the call under their finger, and what keeps it there is holding * [toggleCallAnchored].
* 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.
* *
* Where the touch landed decides the rest, rather than which control was pressed, so everything * 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. * 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 * 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. * 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 * Neither position needs to know the new height, which is what lets them be asked for before it
* for before it exists. A top edge holds by placing the item *above* the row where it already * exists. A top edge holds by placing the item *above* the row where it already is, since that
* is, since that item's bottom edge is the row's top edge whatever happens to the row. A close * item's bottom edge is the row's top edge whatever happens to the row. A close places the row
* places the row itself, using the height it had at the moment it was opened: what the reader * itself, using the height it had at the moment it was opened: what the reader is shutting is
* is shutting is the card they opened, so that is the height it is going back to. * the card they opened, so that is the height it is going back to.
*/ */
fun toggleAnchored( fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding {
row: TranscriptRow,
closing: Boolean,
inner: Boolean = false,
toggle: () -> Unit,
) = expanding {
val info = listState.layoutInfo val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.key == row.key } val item = info.visibleItemsInfo.firstOrNull { it.key == row.key }
if (lastTouch.key == row.key && item != null) { if (lastTouch.key == row.key && item != null) {
@@ -598,21 +593,56 @@ fun SessionScreen(
val closed = if (closing) closedHeights[row.key] else null val closed = if (closing) closedHeights[row.key] else null
when { when {
// The card the reader is shutting, centred on the finger that shut it. // The card the reader is shutting, centred on the finger that shut it.
closing && !inner && closed != null -> closed != null -> listState.requestScrollToItem(item.index, closed / 2 - touch)
listState.requestScrollToItem(item.index, closed / 2 - touch) // An open in the row's top half holds that edge: everything above the row stays
// Everything above the change stays where it is: the row's top edge, which is the // where it is, which is the same as saying the item above it does.
// bottom edge of the item above it. That item does not have to be composed for the lastTouch.y < lastTouch.height / 2 && item.index + 1 < info.totalItemsCount ->
// 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 ->
listState.requestScrollToItem(item.index + 1, -above) listState.requestScrollToItem(item.index + 1, -above)
// What is left is an open in the row's lower half, which wants its bottom edge // 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. // held, and that is what the list does on its own.
else -> {} else -> {}
} }
// Its height as it stands is the height it goes back to when it is shut again. // 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() toggle()
} }
@@ -1657,15 +1687,11 @@ fun SessionScreen(
} }
}, },
isToolExpanded = { it in expandedTools }, isToolExpanded = { it in expandedTools },
// Anchored on the group, not the call: opening one // The scroll is asked for against the group, since
// call makes the whole group taller, and the // that is the row the list knows, and aimed at the
// heading the reader is under is the group's. // call -- see [toggleCallAnchored].
onToolToggle = { id -> onToolToggle = { id, top, height ->
toggleAnchored( toggleCallAnchored(row, id, top, height) {
row,
closing = id in expandedTools,
inner = true,
) {
expandedTools = expandedTools =
if (id in expandedTools) if (id in expandedTools)
expandedTools - id expandedTools - id
@@ -27,6 +27,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape 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.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -196,7 +199,15 @@ fun ToolGroup(
*/ */
onToggle: () -> Unit, onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean, 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, onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
) { ) {
@@ -211,8 +222,10 @@ fun ToolGroup(
} }
return return
} }
val placed = remember { Placed() }
Column( Column(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.onPlaced { placed.top = it.positionInRoot().y }
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
) { ) {
@@ -232,13 +245,19 @@ fun ToolGroup(
verticalArrangement = Arrangement.spacedBy(GROUP_GAP), verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) { ) {
group.calls.forEachIndexed { index, call -> group.calls.forEachIndexed { index, call ->
val card = remember(call.id) { Placed() }
ToolCard( ToolCard(
tool = call, tool = call,
expanded = isToolExpanded(call.id), expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) }, onToggle = {
onToolToggle(call.id, (card.top - placed.top).toInt(), card.height)
},
onAnswer = onAnswer, onAnswer = onAnswer,
image = image, image = image,
shape = connectedShape(index, group.calls.size), 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. * 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 = {}, image: @Composable (String) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */ /** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape, shape: Shape = CardDefaults.shape,
modifier: Modifier = Modifier,
) { ) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val name = toolDisplayName(tool.tool) val name = toolDisplayName(tool.tool)
val output = toolDisplayOutput(tool.tool, tool.output) val output = toolDisplayOutput(tool.tool, tool.output)
val deciding = tool.asks.any { it.answers.isEmpty() } val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding 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)) { Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.titleSmall) Text(name, style = MaterialTheme.typography.titleSmall)