Open a row downwards, from whichever end was pressed

Tapping a group's heading used to send that heading up off the top of the
screen and fill the space above it, so the calls appeared on the far side
of the control that produced them. 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.

The rule now is that the end the reader pressed is the end that must not
move. A heading anchors the top, so the row opens downwards under it; the
bar at the foot of an open group anchors the bottom, so shutting it from
there leaves what follows the group where it is -- which is what already
happened, but by accident of the layout rather than on purpose, and would
have been lost the moment anything else changed.

Bottom is the list's own behaviour and costs nothing. Top is measured
rather than calculated: only the layout knows how tall an open group is,
so `toggleAnchored` reads where the top edge was, lets the change land,
and scrolls by however far it moved.

Applied to every row that opens, not just groups -- a lone tool call and a
peer message are the same gesture, and one of them opening the other way
would be the odder for it.
This commit is contained in:
iris committed 2026-08-30 02:52:45 -04:00
1 parent 3ecc550c1e
commit f4d4c82910
3 files changed
+87 -5

No files matched your search

+7
View File
@@ -624,6 +624,13 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
output; a spinner while `ToolStart` has no matching `ToolEnd`).
- Question cards inline: option buttons for AskUserQuestion, allow/deny for
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` measures the move and scrolls it
back (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**;
mid-run sends become steering messages.
- 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.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
@@ -61,8 +62,11 @@ import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
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,14 @@ private const val RECONNECT_DELAY_MS = 1500L
*/
private const val HISTORY_LOOKAHEAD = 8
/**
* How long to wait for a row to finish changing size before giving up on holding its top edge.
*
* Generous, because it is only reached when the answer never comes: the row is measured on the very
* next layout in every ordinary case.
*/
private const val ANCHOR_TIMEOUT_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 +681,41 @@ 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 used to send the
* heading up off the screen and fill the space above it, so the calls appeared 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]
* measures where the row's top edge was, lets the change land, and scrolls by however far it
* moved. 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.
*
* Given up on after [ANCHOR_TIMEOUT_MS] rather than waited on forever -- a row that never
* settles is one that is no longer on screen, and the reader has moved on.
*/
fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) {
fun topOf() =
listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == key }
?.let { it.offset + it.size }
val before = topOf()
toggle()
if (edge == RowEdge.Bottom || before == null) return
scope.launch {
val after =
withTimeoutOrNull(ANCHOR_TIMEOUT_MS) {
snapshotFlow { topOf() }.filterNotNull().first { it != before }
} ?: return@launch
listState.scrollBy((after - before).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 +1172,23 @@ fun SessionScreen(
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
onToggle = {
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 ->
toggleAnchored(row.key, RowEdge.Top) {
expandedTools =
if (id in expandedTools) expandedTools - id
else expandedTools + id
}
},
onAnswer = { questionId, answers ->
act {
@@ -1158,10 +1212,12 @@ fun SessionScreen(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
toggleAnchored(row.key, RowEdge.Top) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id
else expandedTools + item.id
}
},
onAnswer = { questionId, answers ->
act {
@@ -1203,10 +1259,12 @@ fun SessionScreen(
item = item,
expanded = item.seq in expandedNotes,
onToggle = {
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) }
}
}