fix: duplicate messages on scroll-to-top in chat
Three root causes fixed: - Room Flow observer overwrote deduplicated list during loadMoreHistory() → Added isLoadingHistory flag to suppress Flow updates while loading - beforeId used local IDs (user_/asst_ prefix) that server couldn't resolve, causing server to return ALL messages → Find oldest server-UUID message as cursor - Scroll-to-top LaunchedEffect could double-fire → Added 800ms throttle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll-to-top detection for loading more history
|
||||
// Scroll-to-top detection for loading more history (throttled to prevent double-fire)
|
||||
val isAtTop by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 &&
|
||||
@@ -93,8 +93,14 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
var lastLoadMoreTime by remember { mutableStateOf(0L) }
|
||||
|
||||
LaunchedEffect(isAtTop) {
|
||||
if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone
|
||||
&& (now - lastLoadMoreTime) > 800
|
||||
) {
|
||||
lastLoadMoreTime = now
|
||||
viewModel.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ class ChatViewModel @Inject constructor(
|
||||
|
||||
private var sseJob: Job? = null
|
||||
private var historyFlowJob: Job? = null
|
||||
private var isLoadingHistory = false
|
||||
|
||||
companion object {
|
||||
private const val KEY_SESSION_STATE = "chat_session_state"
|
||||
@@ -181,9 +182,9 @@ class ChatViewModel @Inject constructor(
|
||||
val currentAgentId = _uiState.value.currentAgent?.id
|
||||
historyFlowJob = viewModelScope.launch {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == currentAgentId) {
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == currentAgentId && !isLoadingHistory) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() }.distinctBy { it.id }.distinctBy { it.id },
|
||||
messages = messages.map { it.toUiMessage() }.distinctBy { it.id },
|
||||
sessionId = sessionId
|
||||
)
|
||||
}
|
||||
@@ -243,7 +244,7 @@ class ChatViewModel @Inject constructor(
|
||||
val latestConversation = conversations.firstOrNull()
|
||||
if (latestConversation != null) {
|
||||
chatRepository.getMessages(latestConversation.sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == agentId) {
|
||||
if (messages.isNotEmpty() && _uiState.value.currentAgent?.id == agentId && !isLoadingHistory) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() }.distinctBy { it.id },
|
||||
sessionId = latestConversation.sessionId
|
||||
@@ -312,7 +313,7 @@ class ChatViewModel @Inject constructor(
|
||||
historyFlowJob?.cancel()
|
||||
historyFlowJob = viewModelScope.launch {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
if (messages.isNotEmpty()) {
|
||||
if (messages.isNotEmpty() && !isLoadingHistory) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
messages = messages.map { it.toUiMessage() }.distinctBy { it.id }
|
||||
)
|
||||
@@ -350,10 +351,15 @@ class ChatViewModel @Inject constructor(
|
||||
val oldestMsg = state.messages.firstOrNull() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
isLoadingHistory = true
|
||||
_uiState.value = _uiState.value.copy(isLoadingMore = true)
|
||||
|
||||
// Capture current scroll anchor (first visible item id + offset)
|
||||
val anchorId = oldestMsg.id
|
||||
// Use a server-resolvable ID as cursor: local IDs (user_/asst_/tool_/system_ prefix)
|
||||
// are not in the server DB, so the server would ignore the cursor and return ALL messages.
|
||||
// Find the oldest message whose ID looks like a server UUID.
|
||||
val serverIdPattern = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", RegexOption.IGNORE_CASE)
|
||||
val anchorId = state.messages.firstOrNull { serverIdPattern.matches(it.id) }?.id
|
||||
?: oldestMsg.id
|
||||
|
||||
val result = chatRepository.fetchOlderMessages(
|
||||
agentId = agentId,
|
||||
@@ -377,6 +383,7 @@ class ChatViewModel @Inject constructor(
|
||||
hasMoreMessages = hasMore,
|
||||
isLoadingMore = false
|
||||
)
|
||||
isLoadingHistory = false
|
||||
} else {
|
||||
// API failed — try Room fallback
|
||||
val oldestTimestamp = oldestMsg.createdAt
|
||||
@@ -398,11 +405,13 @@ class ChatViewModel @Inject constructor(
|
||||
hasMoreMessages = roomMsgs.size >= 50,
|
||||
isLoadingMore = false
|
||||
)
|
||||
isLoadingHistory = false
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
hasMoreMessages = false,
|
||||
isLoadingMore = false
|
||||
)
|
||||
isLoadingHistory = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user