diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index be430480fb0..37bb3f472ee 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -265,7 +265,7 @@ class ChatController( } val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") - val history = parseHistory(historyJson, sessionKey = key) + val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } @@ -336,7 +336,7 @@ class ChatController( try { val historyJson = session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") - val history = parseHistory(historyJson, sessionKey = _sessionKey.value) + val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } @@ -450,7 +450,11 @@ class ChatController( } } - private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { + private fun parseHistory( + historyJson: String, + sessionKey: String, + previousMessages: List, + ): ChatHistory { val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) val sid = root["sessionId"].asStringOrNull() val thinkingLevel = root["thinkingLevel"].asStringOrNull() @@ -470,7 +474,12 @@ class ChatController( ) } - return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) + return ChatHistory( + sessionKey = sessionKey, + sessionId = sid, + thinkingLevel = thinkingLevel, + messages = reconcileMessageIds(previous = previousMessages, incoming = messages), + ) } private fun parseMessageContent(el: JsonElement): ChatMessageContent? { @@ -519,6 +528,47 @@ class ChatController( } } +internal fun reconcileMessageIds(previous: List, incoming: List): List { + if (previous.isEmpty() || incoming.isEmpty()) return incoming + + val idsByKey = LinkedHashMap>() + for (message in previous) { + val key = messageIdentityKey(message) ?: continue + idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id) + } + + return incoming.map { message -> + val key = messageIdentityKey(message) ?: return@map message + val ids = idsByKey[key] ?: return@map message + val reusedId = ids.removeFirstOrNull() ?: return@map message + if (ids.isEmpty()) { + idsByKey.remove(key) + } + if (reusedId == message.id) return@map message + message.copy(id = reusedId) + } +} + +internal fun messageIdentityKey(message: ChatMessage): String? { + val role = message.role.trim().lowercase() + if (role.isEmpty()) return null + + val timestamp = message.timestampMs?.toString().orEmpty() + val contentFingerprint = + message.content.joinToString(separator = "\u001E") { part -> + listOf( + part.type.trim().lowercase(), + part.text?.trim().orEmpty(), + part.mimeType?.trim()?.lowercase().orEmpty(), + part.fileName?.trim().orEmpty(), + part.base64?.hashCode()?.toString().orEmpty(), + ).joinToString(separator = "\u001F") + } + + if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null + return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|") +} + private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt index 976972a7831..96d5e7cf7f6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt @@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -34,11 +36,19 @@ fun ChatMessageListCard( modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() + val displayMessages = remember(messages) { messages.asReversed() } + val stream = streamingAssistantText?.trim() - // With reverseLayout the newest item is at index 0 (bottom of screen). - LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { + // New list items/tool rows should animate into view, but token streaming should not restart + // that animation on every delta. + LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) { listState.animateScrollToItem(index = 0) } + LaunchedEffect(stream) { + if (!stream.isNullOrEmpty()) { + listState.scrollToItem(index = 0) + } + } Box(modifier = modifier.fillMaxWidth()) { LazyColumn( @@ -50,8 +60,6 @@ fun ChatMessageListCard( ) { // With reverseLayout = true, index 0 renders at the BOTTOM. // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - - val stream = streamingAssistantText?.trim() if (!stream.isNullOrEmpty()) { item(key = "stream") { ChatStreamingAssistantBubble(text = stream) @@ -70,8 +78,8 @@ fun ChatMessageListCard( } } - items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> - ChatMessageBubble(message = messages[messages.size - 1 - idx]) + items(items = displayMessages, key = { it.id }) { message -> + ChatMessageBubble(message = message) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index a4a93eeceec..201832b9fd3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -160,7 +160,10 @@ private fun ChatThreadSelector( mainSessionKey: String, onSelectSession: (String) -> Unit, ) { - val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val sessionOptions = + remember(sessionKey, sessions, mainSessionKey) { + resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + } Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt new file mode 100644 index 00000000000..936bd526eb8 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt @@ -0,0 +1,81 @@ +package ai.openclaw.app.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ChatControllerMessageIdentityTest { + @Test + fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "msg-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hi")), + timestampMs = 2000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id }) + } + + @Test + fun reconcileMessageIdsLeavesNewMessagesUntouched() { + val previous = + listOf( + ChatMessage( + id = "msg-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ) + + val incoming = + listOf( + ChatMessage( + id = "new-1", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + ), + ChatMessage( + id = "new-2", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "new reply")), + timestampMs = 3000L, + ), + ) + + val reconciled = reconcileMessageIds(previous = previous, incoming = incoming) + + assertEquals("msg-1", reconciled[0].id) + assertEquals("new-2", reconciled[1].id) + assertNotEquals(reconciled[0].id, reconciled[1].id) + } +} diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index b45f8c355e4..4e2b9a27890 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -5,14 +5,6 @@ vi.mock("./webhook-handler.js", () => ({ createWebhookHandler: vi.fn(() => vi.fn()), })); -vi.mock("zod", () => ({ - z: { - object: vi.fn(() => ({ - passthrough: vi.fn(() => ({ _type: "zod-schema" })), - })), - }, -})); - const { createSynologyChatPlugin } = await import("./channel.js"); describe("createSynologyChatPlugin", () => { diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 7c810cd2226..48d16361b1a 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -63,8 +63,8 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin .spyOn(probeModule, "probeTelegram") .mockImplementation(async () => params?.probeOk - ? { ok: true, bot: { username: params.botUsername ?? "bot" } } - : { ok: false }, + ? { ok: true, bot: { username: params.botUsername ?? "bot" }, elapsedMs: 0 } + : { ok: false, elapsedMs: 0 }, ); const collectUnmentionedGroupIds = vi .spyOn(auditModule, "collectTelegramUnmentionedGroupIds")