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:
1 parent
914985b8b2
commit
ee5bef3686
3 files changed
+125
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user