fix: duplicate messages on scroll-to-top in chat
Some checks failed
CI/CD Pipeline / Backend — Lint & Test (push) Has been cancelled
CI/CD Pipeline / Frontend — Lint & Build (push) Has been cancelled
CI/CD Pipeline / Docker — Build Check (push) Has been cancelled

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:
2026-07-01 20:56:32 +08:00
parent f1326bff95
commit 96394c4b9f
2 changed files with 23 additions and 8 deletions

View File

@@ -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()
}
}

View File

@@ -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
}
}
}