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?,