Open a row downwards, and hold the edge that was pressed

Reinstates the reverted downward-opening rows with the two defects that
made the first attempt worse than what it replaced.

The correction waited on the row's top edge and could wait up to half a
second for it to move. A top edge also moves when the reader scrolls, so a
correction still pending would wake on their drag, read the scroll distance
as the row's growth, and undo it -- the transcript jumping on every expand
and refusing to scroll back at all. It now waits on the row's *size*, which
nothing but a resize changes.

The second is why collapsing a group taller than the screen did nothing at
all. Such a group is the list's own anchor item, so as it shrinks it slides
down behind its anchored bottom edge and out of the viewport, and its size
reads as null -- which `withTimeoutOrNull` cannot tell from the null that
means the wait expired. The case most needing the correction was the one
silently skipped. The wait now answers a value that a timeout cannot, and
the distance is read off any row from the pressed one upwards, all of which
move by exactly the row's growth.

Verified on the emulator with ui-trace (~/.local/bin), which samples the
accessibility tree at 60Hz and reports node bounds in device pixels:
expanding and collapsing from a heading hold it to the pixel, collapsing
from the foot bar holds all four rows below it, a drag 120ms after a tap is
left alone, and scrolling back stays put for six seconds. ktfmt, lint and
85 tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-30 11:56:53 -04:00
1 parent ec40499ba4
commit cab21442d6
3 files changed
+150 -19

No files matched your search

@@ -99,6 +99,20 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
return rows
}
/**
* Which end of a row a reader acted on, and therefore which end must not move.
*
* A row has two controls at opposite ends -- the heading that opens it and the bar that shuts it
* again -- and the reader's finger is on one of them. Whichever it is has to stay where it is while
* the row changes size, or the thing they just pressed slides out from under them. The list anchors
* every row's bottom edge by default (see the transcript's `reverseLayout`), so [Bottom] is what
* happens on its own and [Top] is what has to be arranged.
*/
enum class RowEdge {
Top,
Bottom,
}
/**
* Several calls under one heading, closed until somebody asks.
*
@@ -113,14 +127,15 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
fun ToolGroup(
group: TranscriptRow.Tools,
expanded: Boolean,
onToggle: () -> Unit,
/** Told which end was pressed, because this row has a control at each -- see [RowEdge]. */
onToggle: (RowEdge) -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit,
) {
if (!expanded) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Card(Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }) {
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
@@ -133,7 +148,7 @@ fun ToolGroup(
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(12.dp),
modifier = Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }.padding(12.dp),
)
group.calls.forEach { call ->
ToolCard(
@@ -144,7 +159,9 @@ fun ToolGroup(
image = image,
)
}
CollapseBar(onToggle)
// Shutting it from here anchors the other end: the reader is at the bottom of a long
// group, and what they are looking at is what follows it.
CollapseBar { onToggle(RowEdge.Bottom) }
}
}