diff --git a/PLAN.md b/PLAN.md index b57abc2..11abbc3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -671,6 +671,21 @@ 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 keeps still **the end nearest the tap**: touch a row's + upper half and its top edge holds, so it opens downwards; touch its + lower half and the bottom edge holds, as the list does by default. + Which half, rather than which control, so that everything that opens + behaves alike whether or not it has a control at each end — a group's + heading and foot bar simply fall in the halves they already occupy. 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. The correction lives in the *layout* phase + (`Modifier.holdTopEdge`): the measurement that discovers the row's new + height asks the list to shift by that much, via + `requestScrollToItem`, before anything is drawn. Doing it from an + effect instead means the wrong position is drawn once first, which + reads as a flick and gets worse the faster the screen refreshes + (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/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index ff4ad7a..e2b3ef3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -1,6 +1,5 @@ package com.example.aiapp -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -32,10 +31,10 @@ import androidx.compose.ui.unit.dp fun PeerMessageRow( item: TranscriptItem.PeerNote, expanded: Boolean, - onToggle: () -> Unit, + onToggle: (Float) -> Unit, modifier: Modifier = Modifier, ) { - Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(modifier.fillMaxWidth().clickableAt(onToggle)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall) 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 07edf36..567a0cd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -47,6 +47,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -79,6 +82,67 @@ private const val RECONNECT_DELAY_MS = 1500L */ private const val HISTORY_LOOKAHEAD = 8 +/** + * Which row was asked to hold its top edge, and how tall it was when it last measured. + * + * Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are + * written from the layout phase; a snapshot write there that composition reads would schedule + * another recomposition, and the correction has to land inside the frame that is already being laid + * out rather than in a later one. Nothing observes these, so nothing needs to. + * + * [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one. + */ +private class TopEdgeHold { + var key: Any? = null +} + +/** + * Where a row is on screen, so a tap on it can be told which half it landed in. + * + * Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click, + * and observed by nothing. + */ +private class RowBounds { + var top = 0f + var height = 0f + + /** Above this is the row's top half, below it the bottom half. */ + val middle + get() = top + height / 2 +} + +/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */ +private class LastHeight { + var value: Int? = null +} + +/** + * Keeps this row's top edge where it is when the row changes height, if it was asked to. + * + * This runs in the *layout* phase, from the measurement that discovers the new height, and that is + * the whole reason it is a modifier rather than an effect. A correction posted to a coroutine + * arrives a frame or more after the layout it is correcting, so the wrong position is drawn once + * before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling + * from here happens before anything is drawn, so there is no frame to see and nothing that depends + * on how quickly the correction is scheduled. + * + * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of + * exactly that much is what leaves the top edge where it was. + */ +@Composable +private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier { + val last = remember { LastHeight() } + return onSizeChanged { size -> + val previous = last.value + last.value = size.height + // A first measurement has no previous height to have moved from, and a row that came + // back after being scrolled away is a first measurement again. + if (previous == null || previous == size.height || held.key != key) return@onSizeChanged + held.key = null + hold(size.height - previous) + } +} + /** * 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 @@ -477,6 +541,7 @@ fun SessionScreen( onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() + val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(summary.status) } // Seeded from the row this screen was opened from, so a conversation already under way says @@ -669,6 +734,29 @@ fun SessionScreen( } } + /** + * Changes a row's height while the end the reader touched 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 what a tap in a row's lower half already + * gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not + * move, which is what the reader is looking at down there. A tap in the upper half is the other + * case -- left alone it sends the heading under the reader's finger up off the screen and fills + * the space above it, so the calls appear on the far side of the control that produced them -- + * and that one asks the row to hold its top edge instead. + * + * Which half decides it, rather than which control was pressed, so that everything that opens + * behaves the same way whether or not it happens to have a control at each end. A group has two + * and its heading and foot bar land in the halves they are already in; a single call is one + * card, and tapping low on an open one shuts it downward exactly as the bar does. + * + * The correction itself belongs to the measurement -- see [holdTopEdge]. + */ + fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) { + if (at < row.middle) topEdgeHeld.key = key + toggle() + } + // 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 @@ -1120,96 +1208,137 @@ fun SessionScreen( // at the same end. Paging older history is the opposite insertion and was // already fine, and stays fine, because a key survives both. items(rows.asReversed(), key = { it.key }) { row -> - when (row) { - is TranscriptRow.Tools -> - ToolGroup( - group = row, - expanded = row.id in expandedGroups, - onToggle = { - expandedGroups = - if (row.id in expandedGroups) expandedGroups - row.id - else expandedGroups + row.id - }, - isToolExpanded = { it in expandedTools }, - onToolToggle = { id -> - expandedTools = - if (id in expandedTools) expandedTools - id - else expandedTools + id - }, - onAnswer = { questionId, answers -> - act { - answerQuestion(settings, summary.id, questionId, answers) - } - }, - image = { ref -> SessionImage(settings, summary.id, ref) }, - ) - is TranscriptRow.Single -> - when (val item = row.item) { - is TranscriptItem.UserMsg -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = item.text, - images = item.images, - ) - is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, - onToggle = { + val bounds = remember { RowBounds() } + Box( + Modifier.onGloballyPositioned { + bounds.top = it.positionInRoot().y + bounds.height = it.size.height.toFloat() + } + .holdTopEdge(row.key, topEdgeHeld) { grew -> + // Requested rather than scrolled. Scrolling forces a remeasure, + // and forcing one from inside a measure throws; this is the form + // built to be asked for during layout and applied in that pass. + listState.requestScrollToItem( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset + grew, + ) + } + ) { + when (row) { + is TranscriptRow.Tools -> + ToolGroup( + group = row, + expanded = row.id in expandedGroups, + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { + 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, at -> + toggleAnchored(row.key, bounds, at) { expandedTools = - if (item.id in expandedTools) - expandedTools - item.id - else expandedTools + item.id - }, - onAnswer = { questionId, answers -> + if (id in expandedTools) expandedTools - id + else expandedTools + id + } + }, + onAnswer = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> SessionImage(settings, summary.id, ref) }, + ) + is TranscriptRow.Single -> + when (val item = row.item) { + is TranscriptItem.UserMsg -> + UserBubble( + settings = settings, + sessionId = summary.id, + text = item.text, + images = item.images, + ) + is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { + expandedTools = + if (item.id in expandedTools) + expandedTools - item.id + else expandedTools + item.id + } + }, + onAnswer = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answers -> act { answerQuestion( settings, summary.id, - questionId, + item.id, answers, ) } - }, - image = { ref -> SessionImage(settings, summary.id, ref) }, - ) - is TranscriptItem.QuestionCard -> - QuestionRow(item) { answers -> - act { - answerQuestion(settings, summary.id, item.id, answers) } - } - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> - SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - is TranscriptItem.CommandRow -> CommandBubble(item.text) - is TranscriptItem.ClearedNote -> ClearedRow() - is TranscriptItem.CompactedNote -> CompactedRow(item) - is TranscriptItem.PeerNote -> - PeerMessageRow( - item = item, - expanded = item.seq in expandedNotes, - onToggle = { - expandedNotes = - if (item.seq in expandedNotes) - expandedNotes - item.seq - else expandedNotes + item.seq - }, - ) - } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> + SessionImage(settings, summary.id, item.ref) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + is TranscriptItem.CommandRow -> CommandBubble(item.text) + is TranscriptItem.ClearedNote -> ClearedRow() + is TranscriptItem.CompactedNote -> CompactedRow(item) + is TranscriptItem.PeerNote -> + PeerMessageRow( + item = item, + expanded = item.seq in expandedNotes, + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { + 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..d8a946e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -2,6 +2,8 @@ package com.example.aiapp import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,6 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -99,6 +105,38 @@ fun groupToolRuns(items: List): List { return rows } +/** Where a click went down, and what it went down on. See [clickableAt]. */ +private class TapPoint { + var coords: LayoutCoordinates? = null + var y = 0f +} + +/** + * Clickable, and tells the click where on the screen the finger went down. + * + * A row holds still the end nearest the tap when it changes height, so the toggle has to say where + * it was touched. It cannot say which *end* it was: a group is one row with a control at each end + * and a call in the middle, and only the row knows where its own ends are. So this reports a + * position in root coordinates and leaves the meaning to whoever owns the row. + * + * Built on `clickable` rather than replacing it, because `clickable` is what draws the ripple and + * what puts a click action in front of assistive technology. The position is read on the initial + * pass and nothing is consumed, so the click still happens exactly as it would have. + */ +@Composable +fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier { + val tap = remember { TapPoint() } + return onGloballyPositioned { tap.coords = it } + .pointerInput(Unit) { + awaitEachGesture { + val down = + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + tap.y = tap.coords?.localToRoot(down.position)?.y ?: 0f + } + } + .clickable { onClick(tap.y) } +} + /** * Several calls under one heading, closed until somebody asks. * @@ -113,14 +151,17 @@ fun groupToolRuns(items: List): List { fun ToolGroup( group: TranscriptRow.Tools, expanded: Boolean, - onToggle: () -> Unit, + /** + * Told where it was pressed, because this row has a control at each end -- see [clickableAt]. + */ + onToggle: (Float) -> Unit, isToolExpanded: (String) -> Boolean, - onToolToggle: (String) -> Unit, + onToolToggle: (String, Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit, ) { if (!expanded) { - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, @@ -133,28 +174,30 @@ fun ToolGroup( Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(12.dp), + modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp), ) group.calls.forEach { call -> ToolCard( tool = call, expanded = isToolExpanded(call.id), - onToggle = { onToolToggle(call.id) }, + onToggle = { at -> onToolToggle(call.id, at) }, onAnswer = onAnswer, image = image, ) } + // 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) } } /** The bottom half of a group's toggle: an arrow back up to its heading. */ @Composable -private fun CollapseBar(onToggle: () -> Unit) { +private fun CollapseBar(onToggle: (Float) -> Unit) { val colour = MaterialTheme.colorScheme.onSurfaceVariant Row( Modifier.fillMaxWidth() - .clickable(onClick = onToggle) + .clickableAt(onToggle) .semantics { contentDescription = "Collapse these tool calls" } .padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center, @@ -181,14 +224,14 @@ private fun CollapseBar(onToggle: () -> Unit) { fun ToolCard( tool: TranscriptItem.ToolRun, expanded: Boolean, - onToggle: () -> Unit, + onToggle: (Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit = {}, ) { val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val deciding = tool.asks.any { it.answers.isEmpty() } val open = expanded || deciding - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(tool.tool, style = MaterialTheme.typography.titleSmall)