Close a card on the point that was touched

Collapsing held one of the row's edges -- whichever the tap was nearer -- which
is right for opening and wrong for closing: the row that shuts leaves a heading
where a screenful of card was, and both its old edges can be a screen's length
from the finger that shut it. It now keeps the touched point itself, which for a
closed card is the same thing as landing under the hand that closed it. Opening
is unchanged and deliberately so: those rows are small, every point in them is
within a heading's height of both edges, and the edge pressed is what the reader
wants held rather than a fraction of an unbounded expansion.

One number carries both readings -- the share of the row's height above the
touch, spent as it is on a close and rounded to the nearer edge on an open.

The scroll offset the correction asks for goes negative on a close, and has to:
that is the list being asked for the rows below what it has composed, which is
where the newer content comes from when a card gives a screenful back. It was
clamped at zero, which was invisible while every correction was a row growing
and is what left closes uncorrected.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: a 1441px card closed at a quarter of
its height put the collapsed card's top at 743px against 743 predicted, and
opening a card by its heading still holds the heading still.
This commit is contained in:
iris-ai committed 2026-09-16 01:50:33 -04:00
1 parent 827a30768c
commit 463acb28fa
2 files changed
+108 -46

No files matched your search

+22 -9
View File
@@ -1178,15 +1178,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.
- **Expanding a row keeps still the end nearest the tap**: touch a row's - **Opening a row keeps still the end nearest the tap, and closing one
upper half and its top edge holds, so it opens downwards; touch its keeps still the point touched** (2026-09-16). A row about to open is
lower half and the bottom edge holds, as the list does by default. Which small, so both its edges are within a heading's height of the finger and
half, rather than which control, so everything that opens behaves alike the wanted one is the edge pressed: touch the upper half and the top
whether or not it has a control at each end. The transcript is laid out edge holds, so it opens downwards; touch the lower half and the bottom
from the bottom, so a bottom edge is anchored for free and the top one edge holds, as the list does by default. A row about to close leaves
has to be arranged: `Modifier.holdTopEdge` asks the list to shift during almost nothing of itself, and the only place the closed card can
the *layout* phase, before anything is drawn. From an effect instead, sensibly appear is under the finger that closed it — the case that
the wrong position is drawn once first, which reads as a flick. settles it is a card taller than the screen, where holding either edge
throws the card a screen's length from the hand. One number carries
both: the share of the row's height above the touch, spent as it is on a
close and rounded to the nearer edge on an open. Which half, rather than
which control, so everything behaves alike whether or not it has a
control at each end. The transcript is laid out from the bottom, so a
bottom edge is anchored for free and anything else has to be arranged:
`Modifier.holdTouchedPoint` asks the list to shift during the *layout*
phase, before anything is drawn. From an effect instead, the wrong
position is drawn once first, which reads as a flick. The scroll offset
that asks for it **goes negative on a close** — that is the list being
asked for the rows below what it has composed — and clamping it at zero,
which is what the growing-only version did, leaves every close
uncorrected.
- **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
@@ -82,6 +82,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
@@ -168,25 +169,34 @@ private data class HistoryLoadSignal(
) )
/** /**
* Which row was asked to hold its top edge, and how tall it was when it last measured. * Which row was asked to hold a point of itself still, and what share of its height change that
* point is worth; see [holdTouchedPoint].
* *
* Deliberately *not* snapshot state, which is the point of the class. Both fields are written from * Deliberately *not* snapshot state, which is the point of the class. Both fields are written from
* the layout phase; a snapshot write there that composition reads would schedule another * the layout phase; a snapshot write there that composition reads would schedule another
* recomposition, and the correction has to land inside the frame already being laid out. [key] is * recomposition, and the correction has to land inside the frame already being laid out. [key] is
* cleared by the resize it was set for, so it cannot be spent on an unrelated one. * cleared by the resize it was set for, so it cannot be spent on an unrelated one.
*/ */
private class TopEdgeHold { private class TouchHold {
var key: Any? = null var key: Any? = null
/**
* 1 holds the row's top edge, 0 its bottom, and anything between the point that share names.
*/
var share = 0f
} }
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */ /** One row's height between layouts, so a change in it can be noticed. See [holdTouchedPoint]. */
private class LastHeight { private class LastHeight {
var value: Int? = null var value: Int? = null
} }
/** /**
* Which row the last touch landed in, and whether it landed in the row's top half -- the end that * Which row the last touch landed in, and where down that row it landed -- as the share of the
* row should hold when it changes height; see [holdTopEdge]. * row's height that sits above the touch; see [toggleAnchored].
*
* A share rather than a distance because that is what both readings of the touch want: a close
* spends it as it is, and an open rounds it to the nearer edge.
* *
* One slot rather than a map, because only the touch about to toggle something matters: * One slot rather than a map, because only the touch about to toggle something matters:
* [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each * [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each
@@ -194,22 +204,23 @@ private class LastHeight {
*/ */
private class LastTouch { private class LastTouch {
var key: Any? = null var key: Any? = null
var high = false var share = 0f
} }
/** /**
* Keeps this row's top edge where it is when the row changes height, if it was asked to. * Keeps a point of this row still when the row changes height, if it was asked to.
* *
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is * This runs in the *layout* phase, from the measurement that discovers the new height, and that is
* why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or * why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or
* more after the layout it is correcting, so the wrong position is drawn once first -- visible as a * more after the layout it is correcting, so the wrong position is drawn once first -- visible as a
* flick, and worse the faster the screen refreshes. * flick, and worse the faster the screen refreshes.
* *
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of * [hold] is given how far the list has to scroll. The row's bottom edge is held by the list and all
* exactly that much leaves the top edge where it was. * growth goes upward, so scrolling by the whole change keeps the top edge where it was, by none of
* it the bottom edge, and by [TouchHold.share] of it the point that share names.
*/ */
@Composable @Composable
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier { private fun Modifier.holdTouchedPoint(key: Any, held: TouchHold, hold: (Int) -> Unit): Modifier {
val last = remember { LastHeight() } val last = remember { LastHeight() }
return onSizeChanged { size -> return onSizeChanged { size ->
val previous = last.value val previous = last.value
@@ -217,8 +228,9 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
// A first measurement has no previous height to have moved from, and a row that came back // A first measurement has no previous height to have moved from, and a row that came back
// after being scrolled away is a first measurement again. // after being scrolled away is a first measurement again.
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
val share = held.share
held.key = null held.key = null
hold(size.height - previous) hold(((size.height - previous) * share).roundToInt())
} }
} }
@@ -253,7 +265,7 @@ fun SessionScreen(
val isSubagent = subagent != null val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id) val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() } val touchHeld = remember { TouchHold() }
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
@@ -575,20 +587,35 @@ fun SessionScreen(
} }
/** /**
* Changes a row's height while the end the reader touched stays where it is. * Changes a row's height while what the reader touched stays where it is.
* *
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list * The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
* holds still and all growth goes upward. That is what a tap in a row's lower half already * holds still and all growth goes upward. A tap anywhere in a row that is about to grow or
* gets. A tap in the upper half is the other case -- left alone it sends the heading under the * shrink therefore travels, and how far it travels depends on where down the row it landed.
* reader's finger up off the screen -- and that one asks the row to hold its top edge instead.
* *
* Which half decides it, rather than which control was pressed, so everything that opens * [closing] is the difference between the two ends of that, and they want different answers.
* Shutting a row leaves almost nothing of it, so the point under the finger is the only place
* the closed row can sensibly appear -- a row whose top is far off the screen is the case that
* settles it, since holding either edge throws the card the length of the screen away from the
* hand that shut it. Opening one is the opposite: the row is small, every point in it is within
* a heading's height of both edges, and what the reader wants held is the edge they pressed --
* so the touch is rounded to the nearer of the two rather than splitting the difference and
* carrying the heading part of the way up an unbounded expansion.
*
* Where the touch landed decides it, 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.
* *
* The correction itself belongs to the measurement -- see [holdTopEdge]. * The correction itself belongs to the measurement -- see [holdTouchedPoint].
*/ */
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding { fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding {
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key if (lastTouch.key == row.key) {
val share = if (closing) lastTouch.share else if (lastTouch.share > 0.5f) 1f else 0f
// Nothing to correct at zero: the bottom edge is what the list holds on its own.
if (share > 0f) {
touchHeld.key = row.key
touchHeld.share = share
}
}
toggle() toggle()
} }
@@ -1584,25 +1611,33 @@ fun SessionScreen(
is TranscriptUnit.Whole -> { is TranscriptUnit.Whole -> {
val row = unit.row val row = unit.row
Box( Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> Modifier.holdTouchedPoint(row.key, touchHeld) { by ->
// A *request*, not a raw scroll delta: this runs inside // A *request*, not a raw scroll delta: this runs
// the measure pass that discovered the new height, and // inside the measure pass that discovered the new
// a raw delta forces a synchronous remeasure from // height, and a raw delta forces a synchronous
// within measure, which is fatal. The request is // remeasure from within measure, which is fatal. The
// applied by the same frame's next remeasure, so the // request is applied by the same frame's next
// correction still lands before anything is drawn. // remeasure, so the correction still lands before
// Reads unobserved, or this row's measure would inherit // anything is drawn. Reads unobserved, or this row's
// the scroll position as a dependency and remeasure on // measure would inherit the scroll position as a
// every frame. // dependency and remeasure on every frame.
//
// The offset goes negative on a close, and has to:
// that is the list being asked for room below what it
// has composed, which is where the newer rows come
// from when a card gives a screenful back. Clamping it
// at zero -- which is what this did while every
// correction was a row *growing* -- leaves a close
// uncorrected, and the card lands wherever the list
// felt like putting it.
Snapshot.withoutReadObservation { Snapshot.withoutReadObservation {
listState.requestScrollToItem( listState.requestScrollToItem(
listState.firstVisibleItemIndex, listState.firstVisibleItemIndex,
(listState.firstVisibleItemScrollOffset + grew) listState.firstVisibleItemScrollOffset + by,
.coerceAtLeast(0),
) )
} }
} }
// Which half of this row the touch landed in, for // Where down this row the touch landed, for
// [toggleAnchored]. On the initial pass and consuming // [toggleAnchored]. On the initial pass and consuming
// nothing, so every control inside still gets the gesture; // nothing, so every control inside still gets the gesture;
// only visible rows have one, which is what makes a // only visible rows have one, which is what makes a
@@ -1615,7 +1650,12 @@ fun SessionScreen(
pass = PointerEventPass.Initial, pass = PointerEventPass.Initial,
) )
lastTouch.key = row.key lastTouch.key = row.key
lastTouch.high = down.position.y < size.height / 2f lastTouch.share =
1f -
(down.position.y / size.height).coerceIn(
0f,
1f,
)
} }
} }
) { ) {
@@ -1625,7 +1665,10 @@ fun SessionScreen(
group = row, group = row,
expanded = row.key in expandedGroups, expanded = row.key in expandedGroups,
onToggle = { onToggle = {
toggleAnchored(row) { toggleAnchored(
row,
closing = row.key in expandedGroups,
) {
expandedGroups = expandedGroups =
if (row.key in expandedGroups) if (row.key in expandedGroups)
expandedGroups - row.key expandedGroups - row.key
@@ -1637,7 +1680,10 @@ fun SessionScreen(
// call makes the whole group taller, and the // call makes the whole group taller, and the
// heading the reader is under is the group's. // heading the reader is under is the group's.
onToolToggle = { id -> onToolToggle = { id ->
toggleAnchored(row) { toggleAnchored(
row,
closing = id in expandedTools,
) {
expandedTools = expandedTools =
if (id in expandedTools) if (id in expandedTools)
expandedTools - id expandedTools - id
@@ -1682,7 +1728,10 @@ fun SessionScreen(
tool = item, tool = item,
expanded = item.id in expandedTools, expanded = item.id in expandedTools,
onToggle = { onToggle = {
toggleAnchored(row) { toggleAnchored(
row,
closing = item.id in expandedTools,
) {
expandedTools = expandedTools =
if (item.id in expandedTools) if (item.id in expandedTools)
expandedTools - item.id expandedTools - item.id