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.
This commit is contained in:
1 parent
233689ced6
commit
c3e7f07a5d
3 files changed
+67
-24
No files matched your search
@@ -56,6 +56,9 @@ fun ImportScreen(
|
|||||||
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the row
|
// 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.
|
// itself, not a flag, so the dialog can say which session it is about.
|
||||||
var confirming by remember { mutableStateOf<Importable?>(null) }
|
var confirming by remember { mutableStateOf<Importable?>(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) {
|
fun loadSessions(setup: Setup) {
|
||||||
sessions = LoadState.Loading
|
sessions = LoadState.Loading
|
||||||
@@ -134,6 +137,13 @@ fun ImportScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
ChipGroup(
|
||||||
|
label = "Permissions",
|
||||||
|
options = listOf("manual", "acceptEdits", "auto", "bypassPermissions"),
|
||||||
|
selected = permissionMode,
|
||||||
|
onSelect = { permissionMode = it },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
failure?.let {
|
failure?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -156,6 +166,7 @@ fun ImportScreen(
|
|||||||
setup = setup.id,
|
setup = setup.id,
|
||||||
provider = useProvider.name,
|
provider = useProvider.name,
|
||||||
title = "",
|
title = "",
|
||||||
|
permissionMode = permissionMode,
|
||||||
import = session.id,
|
import = session.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
@@ -187,8 +188,27 @@ fun SessionScreen(
|
|||||||
// closing the stream is what unblocks it when this screen goes away.
|
// closing the stream is what unblocks it when this screen goes away.
|
||||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||||
|
|
||||||
LaunchedEffect(items.size) {
|
// Whether the view is pinned to the newest item. It is the reader's
|
||||||
if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1)
|
// 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) {
|
fun act(action: () -> Unit) {
|
||||||
@@ -322,36 +342,44 @@ fun SessionScreen(
|
|||||||
// Always enabled -- a send while the session is running becomes a
|
// Always enabled -- a send while the session is running becomes a
|
||||||
// steering message injected at the next tool boundary, which is
|
// steering message injected at the next tool boundary, which is
|
||||||
// the point of the whole app.
|
// the point of the whole app.
|
||||||
Row(
|
//
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
// The field gets a row of its own, above the buttons: sharing one
|
||||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
// put the full width behind three controls, so the thing being
|
||||||
) {
|
// typed into was the narrowest thing on the row.
|
||||||
TextButton(
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
onClick = {
|
|
||||||
pickImage.launch(
|
|
||||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
|
|
||||||
}
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = input,
|
value = input,
|
||||||
onValueChange = { input = it },
|
onValueChange = { input = it },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)")
|
Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)")
|
||||||
},
|
},
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Row(
|
||||||
if (status == "running" || status == "compacting") {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Text("Stop")
|
) {
|
||||||
|
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") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ fun SpawnScreen(
|
|||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var model by remember { mutableStateOf("") }
|
var model by remember { mutableStateOf("") }
|
||||||
var cwd 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) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
// Only the spawn's own failure. The fetch's lives in `options`: this
|
// 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
|
// one leaves a filled-in form worth keeping, and that one leaves
|
||||||
@@ -338,7 +342,7 @@ fun SpawnScreen(
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChipGroup(
|
fun ChipGroup(
|
||||||
label: String,
|
label: String,
|
||||||
options: List<String>,
|
options: List<String>,
|
||||||
selected: String?,
|
selected: String?,
|
||||||
|
|||||||
Reference in new issue
Block a user