diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 754b3756..600e513e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -2039,15 +2039,24 @@ class ChatViewModel( // → dropped. Use sameLogicalMessage (canonical // result-key match) and always preserve any WS // message that has no REST counterpart. - val unmatchedIncoming = incoming.toMutableList() + val unmatchedIncoming: MutableList = incoming.toMutableList() + val incomingById = HashMap(unmatchedIncoming.size) + for (i in unmatchedIncoming.indices) { + val id = unmatchedIncoming[i]?.id + if (id != null && !incomingById.containsKey(id)) { + incomingById[id] = i + } + } + val mergedList = mutableListOf() for (existing in current.messages) { val existingServerIndex = serverMessageIndex(existing.id, sessionId) if (existingServerIndex != null) { - val matchIdx = unmatchedIncoming.indexOfFirst { it.id == existing.id } - if (matchIdx >= 0) { - mergedList.add(unmatchedIncoming.removeAt(matchIdx)) + val matchIdx = incomingById[existing.id] + if (matchIdx != null && unmatchedIncoming[matchIdx] != null) { + mergedList.add(unmatchedIncoming[matchIdx]!!) + unmatchedIncoming[matchIdx] = null } else { mergedList.add(existing) } @@ -2057,14 +2066,13 @@ class ChatViewModel( // toolName/content equality. val matchIdx = unmatchedIncoming.indexOfFirst { inc -> - sameLogicalMessage(inc, existing) + inc != null && sameLogicalMessage(inc, existing) } if (matchIdx >= 0) { // Prefer the WS copy (richer payload, // real tool name) when available. - val inc = unmatchedIncoming[matchIdx] mergedList.add(existing) - unmatchedIncoming.removeAt(matchIdx) + unmatchedIncoming[matchIdx] = null } else { // No REST counterpart (server hasn't // persisted yet) — KEEP the WS message. @@ -2072,7 +2080,12 @@ class ChatViewModel( } } } - mergedList.addAll(unmatchedIncoming) + + for (inc in unmatchedIncoming) { + if (inc != null) { + mergedList.add(inc) + } + } val merged = mergedList.distinctBy { it.id } if (sameMessages(current.messages, merged)) current else current.copy(messages = merged) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index aaa4e185..21c5398b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -93,6 +93,7 @@ class ChatViewModelTest { mockConnectionStatus.value = ConnectionStatus.DISCONNECTED every { AuthManager.getToken() } returns "test-token" + every { AuthManager.getBaseUrl() } returns "http://test.local/" every { AuthManager.getSelectedProfileId() } returns null every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 @@ -1187,7 +1188,6 @@ class ChatViewModelTest { // unless another test class happened to init it earlier in the JVM // (the order-dependent flakiness). Stub it for determinism — same // pattern as E2eIntegrationTest / ApiClientTest. - every { AuthManager.getBaseUrl() } returns "http://test.local/" if (success) { coEvery { mockApi.getSessions(any(), any(), any()) @@ -1518,7 +1518,6 @@ class ChatViewModelTest { // on the REST-success path (mockkObject spy fall-through → real // uninitialized AuthManager throws). Stub it — same pattern as // stubSession456Rests / E2eIntegrationTest. - every { AuthManager.getBaseUrl() } returns "http://test.local/" // Stub the transcript fetch explicitly (relaxed mocks return null // and muddy the retry path) and capture which session id it is