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 a1460cf..97d2546 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -197,27 +198,24 @@ fun SessionScreen( // closing the stream is what unblocks it when this screen goes away. DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } - // 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 } + // Whether the view is pinned to the newest message. The list is laid + // out from the bottom (see the LazyColumn below), so "newest" is index + // 0 and being pinned is simply being at the start of it. + // + // Read from the scroll rather than remembered as a flag: with the list + // anchored this way there is no moment where new content pushes the + // anchor away, so there is nothing to protect a remembered value from. + val followTail by remember { + 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. + // 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 to listState.layoutInfo.viewportSize.height } - .collect { (count, _) -> - if (followTail && count > 0) listState.scrollToItem(count - 1) - } + snapshotFlow { items.size } + .collect { count -> if (followTail && count > 0) listState.scrollToItem(0) } } 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( state = listState, + reverseLayout = true, modifier = Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(16.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) { is TranscriptItem.UserMsg -> UserBubble(item.text) is TranscriptItem.AssistantMsg ->