Skip to content

Commit 43eda77

Browse files
authored
Fix chat scroll UI (#231)
* 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 * Fix Gson deserialization crash and remove dead scrollTrigger code 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.
1 parent b818840 commit 43eda77

3 files changed

Lines changed: 93 additions & 28 deletions

File tree

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package com.example.executorchllamademo
1111
import java.text.SimpleDateFormat
1212
import java.util.Date
1313
import java.util.Locale
14+
import java.util.UUID
1415

1516
/**
1617
* Represents a chat message in the conversation.
@@ -26,7 +27,8 @@ class Message(
2627
isSent: Boolean,
2728
val messageType: MessageType,
2829
val promptID: Int,
29-
existingTimestamp: Long? = null
30+
existingTimestamp: Long? = null,
31+
val id: String = UUID.randomUUID().toString()
3032
) {
3133
// Use @JvmName to maintain Java compatibility - Java expects getIsSent()
3234
@get:JvmName("getIsSent")
@@ -62,7 +64,7 @@ class Message(
6264
*/
6365
fun copy(): Message {
6466
val sourceText = if (messageType == MessageType.IMAGE) (imagePath ?: "") else text
65-
return Message(sourceText, isSent, messageType, promptID, timestamp).also {
67+
return Message(sourceText, isSent, messageType, promptID, timestamp, id).also {
6668
it.tokensPerSecond = tokensPerSecond
6769
it.totalGenerationTime = totalGenerationTime
6870
it.thinkingContent = thinkingContent

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,38 +10,46 @@ package com.example.executorchllamademo.ui.screens
1010

1111
import androidx.compose.foundation.background
1212
import androidx.compose.foundation.clickable
13-
import androidx.compose.foundation.interaction.MutableInteractionSource
13+
import androidx.compose.foundation.gestures.detectTapGestures
14+
import androidx.compose.foundation.layout.Box
1415
import androidx.compose.foundation.layout.Column
1516
import androidx.compose.foundation.layout.Row
1617
import androidx.compose.foundation.layout.fillMaxSize
1718
import androidx.compose.foundation.layout.fillMaxWidth
1819
import androidx.compose.foundation.layout.imePadding
1920
import androidx.compose.foundation.layout.padding
21+
import androidx.compose.foundation.layout.size
2022
import androidx.compose.foundation.lazy.LazyColumn
21-
import androidx.compose.foundation.lazy.itemsIndexed
23+
import androidx.compose.foundation.lazy.items
2224
import androidx.compose.foundation.lazy.rememberLazyListState
2325
import androidx.compose.material.icons.Icons
2426
import androidx.compose.material.icons.filled.ArrowBack
2527
import androidx.compose.material.icons.filled.Article
28+
import androidx.compose.material.icons.filled.KeyboardArrowDown
2629
import androidx.compose.material.icons.filled.SwapHoriz
2730
import androidx.compose.material3.AlertDialog
2831
import androidx.compose.material3.ExperimentalMaterial3Api
2932
import androidx.compose.material3.Icon
3033
import androidx.compose.material3.IconButton
3134
import androidx.compose.material3.RadioButton
3235
import androidx.compose.material3.Scaffold
36+
import androidx.compose.material3.SmallFloatingActionButton
3337
import androidx.compose.material3.Text
3438
import androidx.compose.material3.TextButton
3539
import androidx.compose.material3.TopAppBar
3640
import androidx.compose.material3.TopAppBarDefaults
3741
import androidx.compose.runtime.Composable
3842
import androidx.compose.runtime.DisposableEffect
3943
import androidx.compose.runtime.LaunchedEffect
44+
import androidx.compose.runtime.derivedStateOf
4045
import androidx.compose.runtime.getValue
4146
import androidx.compose.runtime.mutableStateOf
4247
import androidx.compose.runtime.remember
48+
import androidx.compose.runtime.rememberCoroutineScope
4349
import androidx.compose.runtime.setValue
50+
import androidx.compose.ui.Alignment
4451
import androidx.compose.ui.Modifier
52+
import androidx.compose.ui.input.pointer.pointerInput
4553
import androidx.compose.ui.platform.LocalFocusManager
4654
import androidx.compose.ui.platform.LocalLifecycleOwner
4755
import androidx.compose.ui.res.stringResource
@@ -53,6 +61,7 @@ import com.example.executorchllamademo.ui.components.MessageItem
5361
import com.example.executorchllamademo.ui.theme.LocalAppColors
5462
import com.example.executorchllamademo.ui.viewmodel.ChatViewModel
5563
import kotlinx.coroutines.delay
64+
import kotlinx.coroutines.launch
5665
import androidx.lifecycle.Lifecycle
5766
import androidx.lifecycle.LifecycleEventObserver
5867

@@ -72,14 +81,40 @@ fun ChatScreen(
7281
val listState = rememberLazyListState()
7382
val appColors = LocalAppColors.current
7483
val focusManager = LocalFocusManager.current
84+
val coroutineScope = rememberCoroutineScope()
7585

76-
// Auto-scroll to bottom when new messages are added or content changes during generation
77-
LaunchedEffect(viewModel.messages.size, viewModel.scrollTrigger) {
86+
// Detect whether the user is near the bottom of the list
87+
val isNearBottom by remember {
88+
derivedStateOf {
89+
val layoutInfo = listState.layoutInfo
90+
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
91+
lastVisible >= layoutInfo.totalItemsCount - 2
92+
}
93+
}
94+
95+
// Scroll to bottom when a new message is added (user sent or new response placeholder)
96+
LaunchedEffect(viewModel.messages.size) {
7897
if (viewModel.messages.isNotEmpty()) {
7998
listState.animateScrollToItem(viewModel.messages.size - 1)
8099
}
81100
}
82101

102+
// During generation: poll and scroll only if user is near bottom (throttled)
103+
LaunchedEffect(viewModel.isGenerating) {
104+
if (viewModel.isGenerating) {
105+
while (viewModel.isGenerating) {
106+
delay(300)
107+
if (isNearBottom && viewModel.messages.isNotEmpty()) {
108+
listState.scrollToItem(viewModel.messages.size - 1)
109+
}
110+
}
111+
// Final animated scroll when generation completes
112+
if (isNearBottom && viewModel.messages.isNotEmpty()) {
113+
listState.animateScrollToItem(viewModel.messages.size - 1)
114+
}
115+
}
116+
}
117+
83118
// Periodically update memory usage
84119
LaunchedEffect(Unit) {
85120
while (true) {
@@ -165,27 +200,52 @@ fun ChatScreen(
165200
.fillMaxSize()
166201
.padding(paddingValues)
167202
.imePadding()
168-
.clickable(
169-
indication = null,
170-
interactionSource = remember { MutableInteractionSource() }
171-
) {
172-
focusManager.clearFocus()
203+
.pointerInput(Unit) {
204+
detectTapGestures(onTap = { focusManager.clearFocus() })
173205
}
174206
) {
175-
// Messages list
176-
LazyColumn(
177-
state = listState,
207+
// Messages list with scroll-to-bottom FAB overlay
208+
Box(
178209
modifier = Modifier
179210
.weight(1f)
180211
.fillMaxWidth()
181-
.background(appColors.chatBackground)
182-
.padding(horizontal = 8.dp)
183212
) {
184-
itemsIndexed(
185-
items = viewModel.messages,
186-
key = { index, message -> "${index}_${message.timestamp}_${message.promptID}" }
187-
) { _, message ->
188-
MessageItem(message = message)
213+
LazyColumn(
214+
state = listState,
215+
modifier = Modifier
216+
.fillMaxSize()
217+
.background(appColors.chatBackground)
218+
.padding(horizontal = 8.dp)
219+
) {
220+
items(
221+
items = viewModel.messages,
222+
key = { message -> message.id }
223+
) { message ->
224+
MessageItem(message = message)
225+
}
226+
}
227+
228+
// Scroll-to-bottom FAB: shown when user scrolls up
229+
if (!isNearBottom && viewModel.messages.isNotEmpty()) {
230+
SmallFloatingActionButton(
231+
onClick = {
232+
coroutineScope.launch {
233+
listState.animateScrollToItem(viewModel.messages.size - 1)
234+
}
235+
},
236+
modifier = Modifier
237+
.align(Alignment.BottomEnd)
238+
.padding(12.dp)
239+
.size(36.dp),
240+
containerColor = appColors.navBar
241+
) {
242+
Icon(
243+
imageVector = Icons.Filled.KeyboardArrowDown,
244+
contentDescription = "Scroll to bottom",
245+
tint = appColors.textOnNavBar,
246+
modifier = Modifier.size(20.dp)
247+
)
248+
}
189249
}
190250
}
191251

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import com.example.executorchllamademo.ModelUtils
3131
import com.example.executorchllamademo.PromptFormat
3232
import com.example.executorchllamademo.ModuleSettings
3333
import com.google.gson.Gson
34+
import com.google.gson.GsonBuilder
35+
import com.google.gson.InstanceCreator
3436
import com.google.gson.reflect.TypeToken
3537
import org.json.JSONException
3638
import org.json.JSONObject
@@ -63,10 +65,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
6365
// Thinking mode state: tracks whether we're inside a <think>...</think> block
6466
private var isInThinkingBlock = false
6567

66-
// Counter that increments on each token to trigger auto-scroll during generation
67-
var scrollTrigger by mutableStateOf(0)
68-
private set
69-
7068
private val _selectedImages = mutableStateListOf<Uri>()
7169
val selectedImages: List<Uri> = _selectedImages
7270

@@ -125,7 +123,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
125123

126124
val existingMsgJSON = demoSharedPreferences.getSavedMessages()
127125
if (existingMsgJSON.isNotEmpty()) {
128-
val gson = Gson()
126+
// Use InstanceCreator so that Gson calls the Message constructor (which
127+
// assigns default values like id = UUID). Without this, Gson uses
128+
// Unsafe.allocateInstance() and fields missing from old JSON become null.
129+
val gson = GsonBuilder()
130+
.registerTypeAdapter(Message::class.java, InstanceCreator<Message> {
131+
Message("", false, MessageType.TEXT, 0)
132+
})
133+
.create()
129134
val type = object : TypeToken<ArrayList<Message>>() {}.type
130135
val savedMessages: ArrayList<Message>? = gson.fromJson(existingMsgJSON, type)
131136
savedMessages?.let {
@@ -810,8 +815,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
810815
_messages[index] = updated
811816
resultMessage = updated
812817
}
813-
// Increment scroll trigger to auto-scroll during generation
814-
scrollTrigger++
815818
}
816819

817820
override fun onStats(stats: String) {

0 commit comments

Comments
 (0)