UI: improve autoscroll during token generation

This commit is contained in:
Han Yin 2025-07-09 10:46:39 -07:00
parent 4e07a377a3
commit e58add740d
1 changed files with 65 additions and 10 deletions

View File

@ -38,10 +38,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -61,11 +63,17 @@ import com.example.llama.ui.components.ModelUnloadDialogHandler
import com.example.llama.util.formatMilliSeconds import com.example.llama.util.formatMilliSeconds
import com.example.llama.viewmodel.ConversationViewModel import com.example.llama.viewmodel.ConversationViewModel
import com.example.llama.viewmodel.Message import com.example.llama.viewmodel.Message
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val AUTO_REPOSITION_INTERVAL = 150L
/** /**
* Screen for LLM conversation with user. * Screen for LLM conversation with user.
*/ */
@OptIn(FlowPreview::class)
@Composable @Composable
fun ConversationScreen( fun ConversationScreen(
loadingMetrics: ModelLoadingMetrics, loadingMetrics: ModelLoadingMetrics,
@ -90,25 +98,70 @@ fun ConversationScreen(
var isModelCardExpanded by remember { mutableStateOf(false) } var isModelCardExpanded by remember { mutableStateOf(false) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Auto-scroll to bottom when messages change or when typing // Track the actual rendered size of the last message bubble
val shouldScrollToBottom by remember(messages.size, isGenerating) { var lastMessageBubbleHeight by remember { mutableIntStateOf(0) }
derivedStateOf { true }
// Track if user has manually scrolled up
var userHasScrolledUp by remember { mutableStateOf(false) }
// Detect if user is at the very bottom
val isAtBottom = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
// At bottom if we can see the bottom spacer
lastVisibleItem != null && lastVisibleItem.index == messages.size
}
} }
LaunchedEffect(shouldScrollToBottom, messages.size) { // Reset scroll flag when user returns to bottom
if (messages.isNotEmpty()) { LaunchedEffect(Unit) {
listState.animateScrollToItem(messages.size - 1) snapshotFlow {
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == messages.size
}
.distinctUntilChanged()
.filter { it }
.collect { userHasScrolledUp = false }
}
// Then scroll to bottom spacer instead
LaunchedEffect(isGenerating) {
snapshotFlow {
listState.layoutInfo.visibleItemsInfo.find {
it.index == messages.size - 1
}?.size ?: 0
}
.distinctUntilChanged()
.collect { currentHeight ->
// Only reposition if:
if (currentHeight > lastMessageBubbleHeight // 1. Height increased (new line)
&& !userHasScrolledUp // 2. User hasn't scrolled up
&& isAtBottom.value // 3. User is at bottom
) {
lastMessageBubbleHeight = currentHeight
listState.scrollToItem(index = messages.size, scrollOffset = 0)
} else if (currentHeight > 0) {
lastMessageBubbleHeight = currentHeight
}
}
}
// Detect manual scrolling
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress && !isAtBottom.value) {
userHasScrolledUp = true
} }
} }
// Set up lifecycle-aware message monitoring // Set up lifecycle-aware message monitoring
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// Scroll to bottom when returning to the screen // Scroll to bottom when returning to the screen
if (event == Lifecycle.Event.ON_RESUME) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
coroutineScope.launch { coroutineScope.launch {
listState.scrollToItem(messages.size - 1) listState.scrollToItem(messages.size)
} }
} }
} }
@ -229,8 +282,10 @@ private fun ConversationMessageList(
MessageBubble(message = message) MessageBubble(message = message)
} }
// Add extra space at the bottom for better UX // Add extra space at the bottom for better UX and a scroll target
item { Spacer(modifier = Modifier.height(16.dp)) } item(key = "bottom-spacer") {
Spacer(modifier = Modifier.height(36.dp))
}
} }
} }