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:
iris committed 2026-08-28 22:24:37 -04:00
1 parent 233689ced6
commit c3e7f07a5d
3 files changed
+67 -24

No files matched your search

@@ -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") }
}
}
}