Hold the bottom while typing, and put the status where the answer goes

**Typing moved the newest message out of sight.** The list re-pinned on a
new item and nothing else, but the two things that shrink it while
somebody writes a reply are the field growing from one line to four and
the keyboard opening under it -- neither of which is a new item. So the
message being replied to drifted upward, and the view only came back when
the reply was finally sent, which is the one moment it did not matter.

It now watches the viewport as well as the item count, and only acts while
already pinned.

**The status has moved out of the corner.** As a label up there it said
something about the session; at the end of the transcript it says
something about a place -- this is where the next thing appears -- and
that is where the reader is already looking, because it is where the last
message is.

`exited` is still said, in the same place. Removing the corner label
without it would have left a session whose process is gone looking exactly
like one waiting for input, on a screen whose whole purpose is typing at
it.

Verified on the phone-sized case rather than reasoned about: three lines
of text with the keyboard open keeps the newest message directly above the
field, and sending shows "working" immediately under the sent message.
This commit is contained in:
iris committed 2026-08-28 23:32:35 -04:00
1 parent e03b757dec
commit f402a1f6c8
1 file changed
+39 -3
@@ -262,9 +262,14 @@ fun SessionScreen(
// A new item at the newest end shifts every index by one, so the view
// has to step back to 0 to stay put. One item, instantly -- not a
// journey through the transcript.
// Anything that changes how much room the list has, as well as a new
// item arriving. Typing is the case that gets missed: the field grows
// from one line to four and the keyboard opens under it, and neither
// is a new message, so watching the item count alone leaves the newest
// text drifting out of sight while somebody writes a reply to it.
LaunchedEffect(listState) {
snapshotFlow { items.size }
.collect { count -> if (followTail && count > 0) listState.scrollToItem(0) }
snapshotFlow { Pair(items.size, listState.layoutInfo.viewportSize.height) }
.collect { (count, _) -> if (followTail && count > 0) listState.scrollToItem(0) }
}
// Reaching the far end of what is loaded -- the oldest item, which in
// this layout is the last index -- fetches the page before it.
@@ -393,7 +398,6 @@ fun SessionScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusText(status)
// Beside the provider it reports on, which is the line directly to its left.
//
// Its real home is this provider's settings, which do not exist yet; until they do,
@@ -434,6 +438,38 @@ fun SessionScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Where the next thing will appear: at the end of what has
// happened, which in this layout is the top of the list.
// In the corner it was a label about the session; here it
// is a place, and the eye is already there because that is
// where the newest message is.
if (running) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
Text(
if (status == "compacting") "compacting" else "working",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else if (status == "exited") {
// Still said somewhere: a session whose process is gone
// cannot be typed at, and with the corner label removed
// nothing else on this screen would mention it.
item {
Text(
"exited",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Reversed to match the layout, so index 0 is the newest and
// the reader still sees them in the order they happened.
items(items.asReversed()) { item ->