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:
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.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 ->
|
||||
|
||||
Reference in new issue
Block a user