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:
1 parent
ec40499ba4
commit
cab21442d6
3 files changed
+150
-19
No files matched your search
@@ -5,6 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -61,8 +63,10 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
private const val RECONNECT_DELAY_MS = 1500L
|
||||
|
||||
@@ -79,6 +83,15 @@ private const val RECONNECT_DELAY_MS = 1500L
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
// 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
|
||||
@@ -1125,16 +1216,23 @@ fun SessionScreen(
|
||||
ToolGroup(
|
||||
group = row,
|
||||
expanded = row.id in expandedGroups,
|
||||
onToggle = {
|
||||
expandedGroups =
|
||||
if (row.id in expandedGroups) expandedGroups - row.id
|
||||
else expandedGroups + row.id
|
||||
onToggle = { edge ->
|
||||
toggleAnchored(row.key, edge) {
|
||||
expandedGroups =
|
||||
if (row.id in expandedGroups) expandedGroups - row.id
|
||||
else expandedGroups + row.id
|
||||
}
|
||||
},
|
||||
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 ->
|
||||
expandedTools =
|
||||
if (id in expandedTools) expandedTools - id
|
||||
else expandedTools + id
|
||||
toggleAnchored(row.key, RowEdge.Top) {
|
||||
expandedTools =
|
||||
if (id in expandedTools) expandedTools - id
|
||||
else expandedTools + id
|
||||
}
|
||||
},
|
||||
onAnswer = { questionId, answers ->
|
||||
act {
|
||||
@@ -1158,10 +1256,12 @@ fun SessionScreen(
|
||||
tool = item,
|
||||
expanded = item.id in expandedTools,
|
||||
onToggle = {
|
||||
expandedTools =
|
||||
if (item.id in expandedTools)
|
||||
expandedTools - item.id
|
||||
else expandedTools + item.id
|
||||
toggleAnchored(row.key, RowEdge.Top) {
|
||||
expandedTools =
|
||||
if (item.id in expandedTools)
|
||||
expandedTools - item.id
|
||||
else expandedTools + item.id
|
||||
}
|
||||
},
|
||||
onAnswer = { questionId, answers ->
|
||||
act {
|
||||
@@ -1203,10 +1303,12 @@ fun SessionScreen(
|
||||
item = item,
|
||||
expanded = item.seq in expandedNotes,
|
||||
onToggle = {
|
||||
expandedNotes =
|
||||
if (item.seq in expandedNotes)
|
||||
expandedNotes - item.seq
|
||||
else expandedNotes + item.seq
|
||||
toggleAnchored(row.key, RowEdge.Top) {
|
||||
expandedNotes =
|
||||
if (item.seq in expandedNotes)
|
||||
expandedNotes - item.seq
|
||||
else expandedNotes + item.seq
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user