UI: improve autoscroll during token generation
This commit is contained in:
parent
4e07a377a3
commit
e58add740d
|
|
@ -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 ->
|
||||||
|
// Scroll to bottom when returning to the screen
|
||||||
if (event == Lifecycle.Event.ON_RESUME) {
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
// Scroll to bottom when returning to the screen
|
|
||||||
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue