diff --git a/PLAN.md b/PLAN.md index c95ef0a..f343c89 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index a3974c1..da78ce7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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 = { - 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 +1212,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 +1259,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 + } }, ) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 4d53dd0..d7bae76 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -99,6 +99,20 @@ fun groupToolRuns(items: List): List { 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): List { 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) -> 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) } } }