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
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user