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 b79b0f7da..725b7e3e6 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") @@ -62,7 +64,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 it.thinkingContent = thinkingContent 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 8cedfdc55..c63e9503a 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 c15822ebe..3a1ccedbd 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,10 +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 @@ -125,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 { @@ -810,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) {