Open the transcript at the bottom instead of travelling there

The list was built oldest-first and then scrolled to the end, so opening a
session started at the top and raced downward through everything in it. On
an imported conversation that is nine hundred items measured before a word
is readable, and it was visible every single time.

Laying the list out from the bottom removes the journey rather than hiding
it. The newest message is index 0, so the first frame is already the right
one, and older items are composed only when somebody scrolls back to them
-- which is also what makes a long history cheap rather than something to
load up front.

Following the tail gets simpler as a result. There is no longer a moment
where new content pushes the anchor away, so "am I pinned" is read
straight from the scroll position instead of being remembered across
scrolls, and a new message is one step back to index 0 rather than a jump
across the transcript.

Checked on the 863-event import of this very conversation: a screenshot
one second after opening is already at the newest message, and scrolling
back reaches older ones in the order they happened.
This commit is contained in:
iris committed 2026-08-28 23:02:56 -04:00
1 parent 3f8805a610
commit ba25a5cacf
1 file changed
+33 -20
@@ -34,6 +34,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -197,27 +198,24 @@ 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() } }
// Whether the view is pinned to the newest item. It is the reader's // Whether the view is pinned to the newest message. The list is laid
// scroll that decides: settling anywhere above the bottom releases it, // out from the bottom (see the LazyColumn below), so "newest" is index
// settling back at the bottom re-arms it. Written only when a scroll // 0 and being pinned is simply being at the start of it.
// *ends* so that the pin's state survives the moments when new content //
// has just pushed the bottom away but the reader never moved. // Read from the scroll rather than remembered as a flag: with the list
var followTail by remember { mutableStateOf(true) } // anchored this way there is no moment where new content pushes the
LaunchedEffect(listState) { // anchor away, so there is nothing to protect a remembered value from.
snapshotFlow { listState.isScrollInProgress } val followTail by remember {
.collect { scrolling -> if (!scrolling) followTail = !listState.canScrollForward } derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
} }
// 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)
} }
// 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.
LaunchedEffect(listState) {
snapshotFlow { items.size }
.collect { count -> if (followTail && count > 0) listState.scrollToItem(0) }
} }
LaunchedEffect(summary.setupName, summary.provider) { LaunchedEffect(summary.setupName, summary.provider) {
@@ -324,13 +322,28 @@ fun SessionScreen(
) )
} }
// Laid out from the bottom, with the newest message at index 0.
//
// The obvious arrangement -- oldest first, then scroll to the end
// -- opens at the top and travels the whole transcript to get
// where it belongs. On an imported session that is nine hundred
// items measured before anything is readable, seen as the view
// visibly racing downward every time it opened.
//
// Anchoring at the bottom removes the journey rather than hiding
// it: the first frame is already the newest message, and older
// ones are composed only as somebody scrolls back to them, which
// is also what makes history cheap on a long conversation.
LazyColumn( LazyColumn(
state = listState, state = listState,
reverseLayout = true,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(items) { item -> // 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 ->
when (item) { when (item) {
is TranscriptItem.UserMsg -> UserBubble(item.text) is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.AssistantMsg -> is TranscriptItem.AssistantMsg ->