From c3e7f07a5d1ff4f6899601e0fbaa3c894f3bd922 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 22:24:37 -0400 Subject: [PATCH] Pin the transcript to its tail, and stop asking about every command **The scroll.** The transcript scrolled on new items and nothing else, which missed the two cases that matter most. An imported session's history arrived and left the view wherever it landed; the keyboard opening shrank the viewport and slid the newest messages under the IME, so typing meant typing into a view showing the middle of something. The view is now pinned to the tail, and it is the reader's scroll that decides: settling anywhere above the bottom releases the pin, settling back at the bottom re-arms it. The pin is written only when a scroll *ends*, so it survives the moment when new content has just pushed the bottom away but the reader never moved -- deriving it continuously from "is the bottom visible" would release it on every append, which is the race that makes naive follow-the-tail implementations let go at random. New items and viewport resizes both re-scroll; the jump is instant rather than animated, because an imported session appends hundreds of items at once and animating through them is a light show. **The input field gets a row of its own**, above the buttons. Sharing one row put the full width behind three controls, so the thing being typed into was the narrowest thing on the row. **Permissions default to auto, and importing can choose.** The spawn screen defaulted to "manual" and imports passed no mode at all, so the CLI asked about everything -- and on a phone every ask is a round trip to a question card, which is how "allow Bash?" became the most-answered question in the app. Both paths now default to auto, with the other modes one tap away for a session that warrants caution. Looked at running, all three: an imported session opens at its bottom, the tail stays visible while typing with the keyboard open, and the mode picker shows auto selected. --- .../kotlin/com/example/aiapp/ImportScreen.kt | 11 +++ .../kotlin/com/example/aiapp/SessionScreen.kt | 72 +++++++++++++------ .../kotlin/com/example/aiapp/SpawnScreen.kt | 8 ++- 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index a557db2..da7a1bf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -56,6 +56,9 @@ fun ImportScreen( // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the row // itself, not a flag, so the dialog can say which session it is about. var confirming by remember { mutableStateOf(null) } + // Same default as the spawn screen, and for the same reason: a phone + // is the wrong place to answer "allow Bash?" forty times. + var permissionMode by remember { mutableStateOf("auto") } fun loadSessions(setup: Setup) { sessions = LoadState.Loading @@ -134,6 +137,13 @@ fun ImportScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { + ChipGroup( + label = "Permissions", + options = listOf("manual", "acceptEdits", "auto", "bypassPermissions"), + selected = permissionMode, + onSelect = { permissionMode = it }, + ) + Spacer(Modifier.height(8.dp)) failure?.let { Text(it, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) @@ -156,6 +166,7 @@ fun ImportScreen( setup = setup.id, provider = useProvider.name, title = "", + permissionMode = permissionMode, import = session.id, ) } 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 cad9841..7e8c3de 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap @@ -187,8 +188,27 @@ fun SessionScreen( // closing the stream is what unblocks it when this screen goes away. DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } - LaunchedEffect(items.size) { - if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1) + // Whether the view is pinned to the newest item. It is the reader's + // scroll that decides: settling anywhere above the bottom releases it, + // settling back at the bottom re-arms it. Written only when a scroll + // *ends* so that the pin's state survives the moments when new content + // has just pushed the bottom away but the reader never moved. + var followTail by remember { mutableStateOf(true) } + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .collect { scrolling -> if (!scrolling) followTail = !listState.canScrollForward } + } + // Two things move the bottom out from under the reader: a new item, + // and the viewport shrinking when the keyboard opens. Watching only + // item count handled the first and left the input box typing into a + // view whose tail had slid under the IME. `scrollToItem` rather than + // animated: on an imported session hundreds of items arrive at once, + // and animating through them is a light show, not scrolling. + LaunchedEffect(listState) { + snapshotFlow { items.size to listState.layoutInfo.viewportSize.height } + .collect { (count, _) -> + if (followTail && count > 0) listState.scrollToItem(count - 1) + } } fun act(action: () -> Unit) { @@ -322,36 +342,44 @@ fun SessionScreen( // Always enabled -- a send while the session is running becomes a // steering message injected at the next tool boundary, which is // the point of the whole app. - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(8.dp), - ) { - TextButton( - onClick = { - pickImage.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - ) { - Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") - } + // + // The field gets a row of its own, above the buttons: sharing one + // put the full width behind three controls, so the thing being + // typed into was the narrowest thing on the row. + Column(Modifier.fillMaxWidth().padding(8.dp)) { OutlinedTextField( value = input, onValueChange = { input = it }, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), placeholder = { Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") }, maxLines = 4, ) - Spacer(Modifier.width(8.dp)) - if (status == "running" || status == "compacting") { - OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) { - Text("Stop") + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + TextButton( + onClick = { + pickImage.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + ) { + Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") } - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.weight(1f)) + if (status == "running" || status == "compacting") { + OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) { + Text("Stop") + } + Spacer(Modifier.width(8.dp)) + } + Button(onClick = { send() }) { Text("Send") } } - Button(onClick = { send() }) { Text("Send") } } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index b8dd14f..f64f4e6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -67,7 +67,11 @@ fun SpawnScreen( var title by remember { mutableStateOf("") } var model by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") } - var permissionMode by remember { mutableStateOf("manual") } + // "auto" rather than "manual": on a phone every ask is a round trip to + // a question card, and answering "allow Bash?" dozens of times per task + // is what this app exists to avoid. Manual stays one tap away for a + // session that warrants it. + var permissionMode by remember { mutableStateOf("auto") } var busy by remember { mutableStateOf(false) } // Only the spawn's own failure. The fetch's lives in `options`: this // one leaves a filled-in form worth keeping, and that one leaves @@ -338,7 +342,7 @@ fun SpawnScreen( */ @OptIn(ExperimentalLayoutApi::class) @Composable -private fun ChipGroup( +fun ChipGroup( label: String, options: List, selected: String?,