From 8bd19e99f6ffe942537ad0ff3e5c6ea25b552fce Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Wed, 8 Apr 2026 13:52:35 -0700 Subject: [PATCH 1/2] Fix chat scroll UI: smart auto-scroll, stable keys, and scroll-to-bottom FAB The conversation scroll had several issues: auto-scroll fired on every token during generation (forcing users back to bottom even when reading history), LazyColumn keys included list index (defeating item reuse), and the clickable modifier for keyboard dismiss interfered with scroll gestures. - Add unique id field to Message for stable LazyColumn keys - Replace per-token scrollTrigger with throttled polling (300ms) that only scrolls when user is near bottom (derivedStateOf detection) - Use instant scrollToItem during generation, animateScrollToItem on completion - Add SmallFloatingActionButton to jump back to bottom when scrolled up - Replace clickable with pointerInput/detectTapGestures for keyboard dismiss to avoid scroll gesture interference --- .../example/executorchllamademo/Message.kt | 6 +- .../ui/screens/ChatScreen.kt | 98 +++++++++++++++---- .../ui/viewmodel/ChatViewModel.kt | 6 -- 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt index 0e01ef1e45..0632a3decc 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt @@ -11,6 +11,7 @@ package com.example.executorchllamademo import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.UUID /** * Represents a chat message in the conversation. @@ -26,7 +27,8 @@ class Message( isSent: Boolean, val messageType: MessageType, val promptID: Int, - existingTimestamp: Long? = null + existingTimestamp: Long? = null, + val id: String = UUID.randomUUID().toString() ) { // Use @JvmName to maintain Java compatibility - Java expects getIsSent() @get:JvmName("getIsSent") @@ -56,7 +58,7 @@ class Message( */ fun copy(): Message { val sourceText = if (messageType == MessageType.IMAGE) (imagePath ?: "") else text - return Message(sourceText, isSent, messageType, promptID, timestamp).also { + return Message(sourceText, isSent, messageType, promptID, timestamp, id).also { it.tokensPerSecond = tokensPerSecond it.totalGenerationTime = totalGenerationTime } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt index b4790b5c30..4e2fb337b6 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt @@ -10,19 +10,22 @@ package com.example.executorchllamademo.ui.screens import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api @@ -30,6 +33,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -37,11 +41,15 @@ import androidx.compose.material3.TopAppBarDefaults 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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource @@ -53,6 +61,7 @@ import com.example.executorchllamademo.ui.components.MessageItem import com.example.executorchllamademo.ui.theme.LocalAppColors import com.example.executorchllamademo.ui.viewmodel.ChatViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -72,14 +81,40 @@ fun ChatScreen( val listState = rememberLazyListState() val appColors = LocalAppColors.current val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() - // Auto-scroll to bottom when new messages are added or content changes during generation - LaunchedEffect(viewModel.messages.size, viewModel.scrollTrigger) { + // Detect whether the user is near the bottom of the list + val isNearBottom by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisible >= layoutInfo.totalItemsCount - 2 + } + } + + // Scroll to bottom when a new message is added (user sent or new response placeholder) + LaunchedEffect(viewModel.messages.size) { if (viewModel.messages.isNotEmpty()) { listState.animateScrollToItem(viewModel.messages.size - 1) } } + // During generation: poll and scroll only if user is near bottom (throttled) + LaunchedEffect(viewModel.isGenerating) { + if (viewModel.isGenerating) { + while (viewModel.isGenerating) { + delay(300) + if (isNearBottom && viewModel.messages.isNotEmpty()) { + listState.scrollToItem(viewModel.messages.size - 1) + } + } + // Final animated scroll when generation completes + if (isNearBottom && viewModel.messages.isNotEmpty()) { + listState.animateScrollToItem(viewModel.messages.size - 1) + } + } + } + // Periodically update memory usage LaunchedEffect(Unit) { while (true) { @@ -165,27 +200,52 @@ fun ChatScreen( .fillMaxSize() .padding(paddingValues) .imePadding() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - focusManager.clearFocus() + .pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) } ) { - // Messages list - LazyColumn( - state = listState, + // Messages list with scroll-to-bottom FAB overlay + Box( modifier = Modifier .weight(1f) .fillMaxWidth() - .background(appColors.chatBackground) - .padding(horizontal = 8.dp) ) { - itemsIndexed( - items = viewModel.messages, - key = { index, message -> "${index}_${message.timestamp}_${message.promptID}" } - ) { _, message -> - MessageItem(message = message) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .background(appColors.chatBackground) + .padding(horizontal = 8.dp) + ) { + items( + items = viewModel.messages, + key = { message -> message.id } + ) { message -> + MessageItem(message = message) + } + } + + // Scroll-to-bottom FAB: shown when user scrolls up + if (!isNearBottom && viewModel.messages.isNotEmpty()) { + SmallFloatingActionButton( + onClick = { + coroutineScope.launch { + listState.animateScrollToItem(viewModel.messages.size - 1) + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(36.dp), + containerColor = appColors.navBar + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = "Scroll to bottom", + tint = appColors.textOnNavBar, + modifier = Modifier.size(20.dp) + ) + } } } diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt index e6b1e62ee1..d93edc55a0 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt @@ -60,10 +60,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L var supportsImageInput by mutableStateOf(false) var supportsAudioInput by mutableStateOf(false) - // Counter that increments on each token to trigger auto-scroll during generation - var scrollTrigger by mutableStateOf(0) - private set - private val _selectedImages = mutableStateListOf() val selectedImages: List = _selectedImages @@ -785,8 +781,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L _messages[index] = updated resultMessage = updated } - // Increment scroll trigger to auto-scroll during generation - scrollTrigger++ } } From 4bf0f938bd2b735cb70901177dbda6bd838c61c3 Mon Sep 17 00:00:00 2001 From: Hansong Zhang Date: Thu, 9 Apr 2026 17:35:48 -0700 Subject: [PATCH 2/2] Fix Gson deserialization crash and remove dead scrollTrigger code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use GsonBuilder with InstanceCreator for Message deserialization so that default field values (notably the new `id` field) are properly initialized when loading saved messages from older JSON that lacks the field. Without this, Gson's Unsafe.allocateInstance() leaves `id` as null, crashing LazyColumn's stable-key lookup. Also remove the now-unused `scrollTrigger` state — ChatScreen no longer observes it after the scroll rework, so each-token increments were just wasted snapshot notifications. --- .../ui/viewmodel/ChatViewModel.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt index e690a11c5f..3a1ccedbd9 100644 --- a/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt +++ b/llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt @@ -31,6 +31,8 @@ import com.example.executorchllamademo.ModelUtils import com.example.executorchllamademo.PromptFormat import com.example.executorchllamademo.ModuleSettings import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.InstanceCreator import com.google.gson.reflect.TypeToken import org.json.JSONException import org.json.JSONObject @@ -63,11 +65,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L // Thinking mode state: tracks whether we're inside a ... block private var isInThinkingBlock = false - // Counter that increments on each token to trigger auto-scroll during generation - var scrollTrigger by mutableStateOf(0) - private set - - private val _selectedImages = mutableStateListOf() val selectedImages: List = _selectedImages @@ -126,7 +123,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L val existingMsgJSON = demoSharedPreferences.getSavedMessages() if (existingMsgJSON.isNotEmpty()) { - val gson = Gson() + // Use InstanceCreator so that Gson calls the Message constructor (which + // assigns default values like id = UUID). Without this, Gson uses + // Unsafe.allocateInstance() and fields missing from old JSON become null. + val gson = GsonBuilder() + .registerTypeAdapter(Message::class.java, InstanceCreator { + Message("", false, MessageType.TEXT, 0) + }) + .create() val type = object : TypeToken>() {}.type val savedMessages: ArrayList? = gson.fromJson(existingMsgJSON, type) savedMessages?.let { @@ -811,8 +815,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L _messages[index] = updated resultMessage = updated } - // Increment scroll trigger to auto-scroll during generation - scrollTrigger++ } override fun onStats(stats: String) {