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.
One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer
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
upper half and its top edge holds, so it opens downwards; touch its
lower half and the bottom edge holds, as the list does by default. Which
half, rather than which control, so everything that opens 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 the top one
has to be arranged: `Modifier.holdTopEdge` 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.
- **Opening a row keeps still the end nearest the tap, and closing one
keeps still the point touched** (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
almost nothing of itself, and the only place the closed card can
sensibly appear is under the finger that closed it — the case that
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 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
@@ -82,6 +82,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
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
* 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
* 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
/**
* 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 {
var value: Int? = null
}
/**
* Which row the last touch landed in, and whether it landed in the row's top half -- the end that
* row should hold when it changes height; see [holdTopEdge].
* Which row the last touch landed in, and where down that row it landed -- as the share of the
* 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:
* [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 {
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
* 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
* 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
* exactly that much leaves the top edge where it was.
* [hold] is given how far the list has to scroll. The row's bottom edge is held by the list and all
* 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
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() }
return onSizeChanged { size ->
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
// after being scrolled away is a first measurement again.
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
val share = held.share
held.key = null
hold(size.height - previous)
hold(((size.height - previous) * share).roundToInt())
}
}
@@ -253,7 +265,7 @@ fun SessionScreen(
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
val touchHeld = remember { TouchHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
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
* holds still and all growth goes upward. That is what a tap in a row's lower half already
* gets. A tap in the upper half is the other case -- left alone it sends the heading under the
* reader's finger up off the screen -- and that one asks the row to hold its top edge instead.
* holds still and all growth goes upward. A tap anywhere in a row that is about to grow or
* shrink therefore travels, and how far it travels depends on where down the row it landed.
*
* 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.
*
* 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 {
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding {
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()
}
@@ -1584,25 +1611,33 @@ fun SessionScreen(
is TranscriptUnit.Whole -> {
val row = unit.row
Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
// A *request*, not a raw scroll delta: this runs inside
// the measure pass that discovered the new height, and
// a raw delta forces a synchronous remeasure from
// within measure, which is fatal. The request is
// applied by the same frame's next remeasure, so the
// correction still lands before anything is drawn.
// Reads unobserved, or this row's measure would inherit
// the scroll position as a dependency and remeasure on
// every frame.
Modifier.holdTouchedPoint(row.key, touchHeld) { by ->
// A *request*, not a raw scroll delta: this runs
// inside the measure pass that discovered the new
// height, and a raw delta forces a synchronous
// remeasure from within measure, which is fatal. The
// request is applied by the same frame's next
// remeasure, so the correction still lands before
// anything is drawn. Reads unobserved, or this row's
// measure would inherit the scroll position as a
// 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 {
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
(listState.firstVisibleItemScrollOffset + grew)
.coerceAtLeast(0),
listState.firstVisibleItemScrollOffset + by,
)
}
}
// 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
// nothing, so every control inside still gets the gesture;
// only visible rows have one, which is what makes a
@@ -1615,7 +1650,12 @@ fun SessionScreen(
pass = PointerEventPass.Initial,
)
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,
expanded = row.key in expandedGroups,
onToggle = {
toggleAnchored(row) {
toggleAnchored(
row,
closing = row.key in expandedGroups,
) {
expandedGroups =
if (row.key in expandedGroups)
expandedGroups - row.key
@@ -1637,7 +1680,10 @@ fun SessionScreen(
// call makes the whole group taller, and the
// heading the reader is under is the group's.
onToolToggle = { id ->
toggleAnchored(row) {
toggleAnchored(
row,
closing = id in expandedTools,
) {
expandedTools =
if (id in expandedTools)
expandedTools - id
@@ -1682,7 +1728,10 @@ fun SessionScreen(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
toggleAnchored(row) {
toggleAnchored(
row,
closing = item.id in expandedTools,
) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id