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

+12
View File
@@ -624,6 +624,18 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
output; a spinner while `ToolStart` has no matching `ToolEnd`). output; a spinner while `ToolStart` has no matching `ToolEnd`).
- Question cards inline: option buttons for AskUserQuestion, allow/deny for - Question cards inline: option buttons for AskUserQuestion, allow/deny for
permissions, free-text where allowed. permissions, free-text where allowed.
- Expanding a row **opens downwards**: whichever end the reader pressed —
a group's heading or the bar at its foot — is the end that stays put,
and the row grows away from it. The transcript is laid out from the
bottom, so a row's bottom edge is anchored for free and the top one
has to be arranged. `toggleAnchored` waits on the row's *size*, which
a scroll cannot change — a version that waited on the top edge instead
mistook the reader's own drag for the row resizing and undid it — and
then reads how far things moved off any row from the pressed one
upwards, since a group taller than the screen disappears under the
bottom edge as it shrinks and cannot report its own move (2026-08-30,
asked for after groups opened upwards and sent their own heading off
the top of the screen).
- Input bar: text, attach (camera/gallery/file), send — **always enabled**; - Input bar: text, attach (camera/gallery/file), send — **always enabled**;
mid-run sends become steering messages. mid-run sends become steering messages.
- Top bar: model chip (tap to change), stop button while running, token - Top bar: model chip (tap to change), stop button while running, token
@@ -5,6 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -61,8 +63,10 @@ import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private const val RECONNECT_DELAY_MS = 1500L private const val RECONNECT_DELAY_MS = 1500L
@@ -79,6 +83,15 @@ private const val RECONNECT_DELAY_MS = 1500L
*/ */
private const val HISTORY_LOOKAHEAD = 8 private const val HISTORY_LOOKAHEAD = 8
/**
* How long to keep holding a row's top edge while the row settles to its new height.
*
* Long enough for the second and third layout passes a row can take to reach its final height, and
* short enough that it is over before the reader could have started scrolling for their own
* reasons. It always runs to the end: there is no way to ask whether more passes are coming.
*/
private const val ANCHOR_SETTLE_MS = 500L
/** /**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes * stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
@@ -669,6 +682,84 @@ fun SessionScreen(
} }
} }
/**
* Changes a row's height while the end the reader pressed 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 right for the bar at the foot of an open
* group -- shut it from there and what follows it does not move, which is what the reader is
* looking at. It is exactly wrong for a heading: opening a group from the top sends the heading
* up off the screen and fills the space above it, so the calls appear on the far side of the
* control that produced them.
*
* So [RowEdge.Bottom] is the list's own behaviour and does nothing extra, and [RowEdge.Top]
* scrolls back by however much the row grew. It has to be measured rather than worked out: only
* the layout knows how tall an open group is, and it depends on the calls in it.
*
* What is measured is the row's *size*, and that is the whole reason this works. The obvious
* thing to measure is where its top edge went, and an earlier version did -- but a top edge
* also moves when the reader scrolls, so a correction still waiting for the layout would
* instead wake on the reader's own drag, read their scroll distance as the row's growth, and
* undo it. That was reported as the transcript jumping on every expand and refusing to scroll
* back at all. A size does not change when anybody scrolls, so the two cannot be confused.
*
* The row's bottom edge is held by the list, so scrolling by the growth is what puts the top
* edge back, and it is done for every step of that growth until [ANCHOR_SETTLE_MS] is up.
*/
fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) {
fun rowOf() = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
val row = rowOf()
toggle()
// Not on screen when it was pressed, so there is no edge of it to hold.
if (edge == RowEdge.Bottom || row == null) return
// Read out now, as numbers: the layout hands back a reused object per slot, so holding
// one and reading it later describes whatever item took that slot since.
val rowIndex = row.index
val rowSize = row.size
// How far the row's top edge moved, read off any row that moved with it. The row and
// everything above it shift by exactly the row's growth, so they all give the same
// answer -- and several are recorded because which of them can answer varies. A row
// that shrinks slides down behind its own anchored bottom edge, and a group taller
// than the screen goes under the bottom of it entirely, leaving nothing of itself to
// measure.
fun edgeOf(item: LazyListItemInfo) =
if (item.index == rowIndex) item.offset + item.size else item.offset
val marks =
listState.layoutInfo.visibleItemsInfo
.filter { it.index >= rowIndex }
.associate { it.key to edgeOf(it) }
scope.launch {
// Waited on by *size*, which is the one thing a scroll cannot change. An earlier
// version waited on the top edge instead, so a correction still pending would wake
// on the reader's own drag, read their scroll distance as the row's growth and undo
// it -- reported as the transcript jumping on every expand and refusing to scroll
// back at all.
//
// The wait answers `true` rather than the size it saw, because the size it saw is
// sometimes `null` -- that is the row leaving the screen, which is a real answer and
// the one this has to handle. Returned straight out of `withTimeoutOrNull` it would
// be the same value that means "never happened", and the case needing the correction
// most was the case silently skipped.
val settled =
withTimeoutOrNull(ANCHOR_SETTLE_MS) {
snapshotFlow { rowOf()?.size }.first { it != rowSize }
true
}
if (settled == null) return@launch
// Never against a finger already on the screen: the reader is placing the list
// themselves, and that is also the only way a mark could have moved for a reason
// other than the row resizing.
if (listState.isScrollInProgress) return@launch
val moved =
listState.layoutInfo.visibleItemsInfo.firstNotNullOfOrNull { item ->
marks[item.key]?.let { edgeOf(item) - it }
} ?: return@launch
listState.scrollBy(moved.toFloat())
}
}
// A compaction reports nothing about its own progress -- measured against the CLI, which // A compaction reports nothing about its own progress -- measured against the CLI, which
// says it has started, and then says nothing at all until it is done. So what this counts is // says it has started, and then says nothing at all until it is done. So what this counts is
// the one thing anybody here can measure: how long it has been going. A bar filling up would // the one thing anybody here can measure: how long it has been going. A bar filling up would
@@ -1125,16 +1216,23 @@ fun SessionScreen(
ToolGroup( ToolGroup(
group = row, group = row,
expanded = row.id in expandedGroups, expanded = row.id in expandedGroups,
onToggle = { onToggle = { edge ->
expandedGroups = toggleAnchored(row.key, edge) {
if (row.id in expandedGroups) expandedGroups - row.id expandedGroups =
else expandedGroups + row.id if (row.id in expandedGroups) expandedGroups - row.id
else expandedGroups + row.id
}
}, },
isToolExpanded = { it in expandedTools }, 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 -> onToolToggle = { id ->
expandedTools = toggleAnchored(row.key, RowEdge.Top) {
if (id in expandedTools) expandedTools - id expandedTools =
else expandedTools + id if (id in expandedTools) expandedTools - id
else expandedTools + id
}
}, },
onAnswer = { questionId, answers -> onAnswer = { questionId, answers ->
act { act {
@@ -1158,10 +1256,12 @@ fun SessionScreen(
tool = item, tool = item,
expanded = item.id in expandedTools, expanded = item.id in expandedTools,
onToggle = { onToggle = {
expandedTools = toggleAnchored(row.key, RowEdge.Top) {
if (item.id in expandedTools) expandedTools =
expandedTools - item.id if (item.id in expandedTools)
else expandedTools + item.id expandedTools - item.id
else expandedTools + item.id
}
}, },
onAnswer = { questionId, answers -> onAnswer = { questionId, answers ->
act { act {
@@ -1203,10 +1303,12 @@ fun SessionScreen(
item = item, item = item,
expanded = item.seq in expandedNotes, expanded = item.seq in expandedNotes,
onToggle = { onToggle = {
expandedNotes = toggleAnchored(row.key, RowEdge.Top) {
if (item.seq in expandedNotes) expandedNotes =
expandedNotes - item.seq if (item.seq in expandedNotes)
else expandedNotes + item.seq expandedNotes - item.seq
else expandedNotes + item.seq
}
}, },
) )
} }
@@ -99,6 +99,20 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
return rows 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. * Several calls under one heading, closed until somebody asks.
* *
@@ -113,14 +127,15 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
fun ToolGroup( fun ToolGroup(
group: TranscriptRow.Tools, group: TranscriptRow.Tools,
expanded: Boolean, 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, isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit, onToolToggle: (String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit, onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit, image: @Composable (String) -> Unit,
) { ) {
if (!expanded) { if (!expanded) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { Card(Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }) {
Text( Text(
"Called ${group.calls.size} tools", "Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
@@ -133,7 +148,7 @@ fun ToolGroup(
Text( Text(
"Called ${group.calls.size} tools", "Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall, 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 -> group.calls.forEach { call ->
ToolCard( ToolCard(
@@ -144,7 +159,9 @@ fun ToolGroup(
image = image, 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) }
} }
} }