From 0c336ecc4dee75be1d5a01fc09c16a11ee21df80 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:15:11 +0300 Subject: [PATCH] feat(chat): show agent message reactions (#1422) --- .../data/model/MessageReaction.kt | 33 +++++++++++ .../data/model/SessionMessagesResponse.kt | 4 ++ .../m57/hermescontrol/data/ws/EventParser.kt | 15 +++++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 12 ++++ .../m57/hermescontrol/ui/chat/ChatMessage.kt | 3 + .../ui/chat/ChatServerMessageMapper.kt | 1 + .../ui/chat/ChatTranscriptMerge.kt | 4 ++ .../ui/chat/ChatWsEventReducer.kt | 19 +++++++ .../chat/components/MessageReactionChips.kt | 44 ++++++++++++++ .../chat/fullbleed/FullBleedAgentMessage.kt | 5 ++ .../ui/chat/fullbleed/FullBleedChatList.kt | 10 ++++ .../hermescontrol/data/ws/EventParserTest.kt | 47 +++++++++++++++ .../chat/ChatServerMessageMapperPurityTest.kt | 35 ++++++++++++ .../ui/chat/ChatWsEventReducerTest.kt | 57 +++++++++++++++++++ 14 files changed, 289 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt new file mode 100644 index 00000000..a95a6bb2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessageReaction.kt @@ -0,0 +1,33 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** One emoji reaction on a message. Tapback semantics: at most one per [author] per message. */ +data class MessageReaction( + val emoji: String, + val author: String, +) + +/** Reactions carried by a live `message.reaction` event payload (`reactions` list of maps). */ +internal fun parseMessageReactions(raw: Any?): List = + (raw as? List<*>).orEmpty().mapNotNull { item -> + val map = item as? Map<*, *> ?: return@mapNotNull null + val emoji = (map["emoji"] as? String)?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MessageReaction(emoji = emoji, author = map["author"] as? String ?: "") + } + +/** Reactions persisted under `display_metadata.reactions` on a REST transcript row. */ +internal fun parseMessageReactions(displayMetadata: JsonElement?): List { + val list = (displayMetadata as? JsonObject)?.get("reactions") as? JsonArray ?: return emptyList() + return list.mapNotNull { item -> + val obj = item as? JsonObject ?: return@mapNotNull null + val emoji = + (obj["emoji"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val author = (obj["author"] as? JsonPrimitive)?.takeIf { it.isString }?.content.orEmpty() + MessageReaction(emoji = emoji, author = author) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index a5a5e1a1..1e0b6ca0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -75,6 +75,10 @@ data class SessionMessage( val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + /** Emoji reactions persisted on this row (`display_metadata.reactions`). */ + val reactions: List + get() = parseMessageReactions(display_metadata) + val tokenCount: Int? get() { val prim = token_count as? JsonPrimitive ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 99639aa4..f4d56a93 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.data.ws import android.util.Log +import com.m57.hermescontrol.data.model.parseMessageReactions import com.m57.hermescontrol.ui.chat.extractTodosFromMap import kotlinx.serialization.json.JsonObject @@ -333,6 +334,20 @@ object EventParser { WsEvent.ReactionEvent(kind) } + "message.reaction" -> { + val rowId = (payload?.get("row_id") as? Number)?.toLong() + if (rowId == null) { + WsEvent.Unknown(rawJson) + } else { + WsEvent.MessageReactionUpdated( + rowId = rowId, + reactions = parseMessageReactions(payload["reactions"]), + role = payload["role"] as? String ?: "", + sessionId = sessionId, + ) + } + } + "approval.request" -> { val command = payload?.get("command") as? String val description = payload?.get("description") as? String diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index e5cf6c26..0f519032 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -445,6 +445,18 @@ sealed class WsEvent { val kind: String = "", ) : WsEvent() + /** + * `message.reaction`: the agent (or another client) set/cleared a tapback on a stored + * message. [reactions] is the row's full list after the write; [rowId] is the gateway + * SQLite row id, matched against `ChatMessage.serverRowId`. + */ + data class MessageReactionUpdated( + val rowId: Long, + val reactions: List, + val role: String, + val sessionId: String?, + ) : WsEvent() + // ── Replay resync (internal) ────────────────────────────────────────── /** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 78fc4eaa..485cc931 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.MessageReaction import java.util.UUID /** @@ -113,6 +114,8 @@ data class ChatMessage( * Scoped to the owning profile store; transient. */ val serverRowId: Long? = null, + /** Emoji tapbacks on this message (REST `display_metadata` or live `message.reaction`); transient. */ + val reactions: List = emptyList(), ) /** Cached REST rows already carry their canonical identity in the persisted primary key. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index a620b5bd..af73df3f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -274,6 +274,7 @@ internal fun mapServerMessages( tokenCount = tokenCount, completionId = completionId, serverRowId = msg.id?.toLong()?.takeIf { it > 0L }, + reactions = msg.reactions, ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt index f3f79c10..c6b38fbd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatTranscriptMerge.kt @@ -349,6 +349,7 @@ internal fun dedupeCachedMessages( message.copy( restId = it.canonicalRestId, serverRowId = message.serverRowId ?: it.serverRowId, + reactions = it.reactions.ifEmpty { message.reactions }, completionId = message.completionId ?: it.completionId, displayKind = message.displayKind ?: it.displayKind, isRestoredUnconfirmed = false, @@ -419,6 +420,7 @@ internal fun mergeCachedTranscriptPage( content = preservedContent ?: rich.content, restId = match.canonicalRestId ?: message.canonicalRestId, serverRowId = match.serverRowId ?: message.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, completionId = match.completionId ?: message.completionId, displayKind = match.displayKind ?: message.displayKind, isRestoredUnconfirmed = @@ -457,6 +459,7 @@ internal fun mergeTranscriptWithLive( match.copy( restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, serverRowId = message.serverRowId ?: match.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, displayKind = message.displayKind ?: match.displayKind, isHistoricalCache = false, isRestoredUnconfirmed = false, @@ -476,6 +479,7 @@ internal fun mergeTranscriptWithLive( match.copy( restId = message.canonicalRestId ?: match.canonicalRestId, serverRowId = message.serverRowId ?: match.serverRowId, + reactions = message.reactions.ifEmpty { match.reactions }, content = mergedContent, timestamp = message.timestamp, isStreaming = message.isStreaming, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 9c2b5e04..65ecedfe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -129,6 +129,7 @@ object ChatWsEventReducer { is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId is WsEvent.TranscriptResyncRequired -> event.sessionId + is WsEvent.MessageReactionUpdated -> event.sessionId is WsEvent.VaultUnlockRequest -> event.sessionId is WsEvent.VaultUnlockExpire -> event.sessionId is WsEvent.VaultSaveLoginRequest -> event.sessionId @@ -252,6 +253,8 @@ object ChatWsEventReducer { // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.MessageReactionUpdated -> onMessageReaction(state, streamingState, event) + // Change events (issue #784) are consumed by their screens' ViewModels is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState) @@ -1079,6 +1082,22 @@ object ChatWsEventReducer { } } + /** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */ + private fun onMessageReaction( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.MessageReactionUpdated, + ): ReducerResult { + if (state.messages.none { it.serverRowId == event.rowId }) { + return ReducerResult(state = state, streamingState = streamingState) + } + val messages = + state.messages.map { + if (it.serverRowId == event.rowId) it.copy(reactions = event.reactions) else it + } + return ReducerResult(state = state.copy(messages = messages), streamingState = streamingState) + } + private fun onUnknown( state: ChatUiState, streamingState: StreamingState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt new file mode 100644 index 00000000..b39c405e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageReactionChips.kt @@ -0,0 +1,44 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.data.model.MessageReaction + +/** + * Tapback chips shown under a message. One reaction per author, so a short row is enough; + * renders nothing when [reactions] is empty. + */ +@Composable +fun MessageReactionChips( + reactions: List, + modifier: Modifier = Modifier, +) { + if (reactions.isEmpty()) return + Row( + modifier = modifier.testTag("message_reactions"), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + reactions.forEach { reaction -> + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 0.dp, + ) { + Text( + text = reaction.emoji, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 3494f339..a95890a3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -41,6 +41,7 @@ import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MessageSegment import com.m57.hermescontrol.ui.chat.TokenEstimator +import com.m57.hermescontrol.ui.chat.components.MessageReactionChips import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback import com.m57.hermescontrol.ui.chat.splitByMedia @@ -143,6 +144,10 @@ internal fun FullBleedAgentMessage( } } + if (!message.isStreaming) { + MessageReactionChips(message.reactions, Modifier.padding(top = 2.dp)) + } + if (!message.isStreaming && message.content.isNotBlank()) { val showTokenStat = messageStatsEnabled && showAssistantMessageTokens && diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 126087a8..bf93897c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -41,6 +41,7 @@ import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble +import com.m57.hermescontrol.ui.chat.components.MessageReactionChips import com.m57.hermescontrol.ui.chat.components.ReasoningCard import com.m57.hermescontrol.ui.chat.components.VaultCodeCard import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard @@ -311,6 +312,15 @@ fun FullBleedChatList( messageStatsEnabled = messageStatsEnabled, showUserMessageTokens = showUserMessageTokens, ) + if (userMessage.reactions.isNotEmpty()) { + MessageReactionChips( + reactions = userMessage.reactions, + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 12.dp), + ) + } } } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 892b9d69..08ad2389 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.MessageReaction import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkAll @@ -928,6 +929,52 @@ class EventParserTest { ) } + @Test + fun testParseMessageReaction_returnsMessageReactionUpdated() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "message.reaction", + "session_id" to "s1", + "payload" to + mapOf( + "row_id" to 42, + "role" to "user", + "reactions" to + listOf( + mapOf("emoji" to "\uD83D\uDE02", "author" to "agent", "at" to 1.0), + mapOf("emoji" to "", "author" to "user"), + ), + ), + ), + ) + val event = EventParser.parse(response, "") as WsEvent.MessageReactionUpdated + assertEquals(42L, event.rowId) + assertEquals("user", event.role) + assertEquals("s1", event.sessionId) + assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), event.reactions) + } + + @Test + fun testParseMessageReaction_withoutRowId_isUnknown() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = mapOf("type" to "message.reaction", "payload" to mapOf("reactions" to emptyList())), + ) + assertTrue(EventParser.parse(response, "") is WsEvent.Unknown) + } + @Test fun testParseUnknownType_returnsUnknownEvent() { val response = diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt index 81feb1bf..baa54b7c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapperPurityTest.kt @@ -1,8 +1,12 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.MessageReaction import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.notification.ReplyNotificationTarget import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -59,4 +63,35 @@ class ChatServerMessageMapperPurityTest { assertEquals(42L, mapped.single().timestamp) } + + @Test + fun persistedReactionsHydrateFromDisplayMetadata() { + val row = + SessionMessage( + id = 5, + role = "user", + content = JsonPrimitive("hello"), + display_metadata = + buildJsonObject { + put( + "reactions", + buildJsonArray { + add( + buildJsonObject { + put("emoji", "\uD83D\uDE02") + put("author", "agent") + }, + ) + add(buildJsonObject { put("author", "user") }) + }, + ) + }, + ) + val mapped = mapServerMessages("s", listOf(row), 0, true, emptyList()).single() + assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), mapped.reactions) + assertEquals( + emptyList(), + mapServerMessages("s", listOf(assistant(6, "x")), 0, true, emptyList()).single().reactions, + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index fc25edfa..e3cbb3bb 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.MessageReaction import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.ws.WsEvent import org.junit.Assert.assertEquals @@ -1642,4 +1643,60 @@ class ChatWsEventReducerTest { assertEquals("idle", result.state.compressionStatus) assertEquals(initialMessages, result.state.messages) } + + @Test + fun messageReactionPaintsOnlyTheRowWithTheMatchingServerId() { + val target = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L) + val other = ChatMessage(id = "b", role = MessageRole.ASSISTANT, content = "yo", serverRowId = 8L) + val state = ChatUiState(currentSessionId = "s", messages = listOf(target, other)) + val reactions = listOf(MessageReaction("\u2764\uFE0F", "agent")) + val result = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(7L, reactions, "user", "s"), + "s", + ) + assertEquals(reactions, result.state.messages[0].reactions) + assertTrue( + result.state.messages[1] + .reactions + .isEmpty(), + ) + val retracted = + ChatWsEventReducer.reduce( + result.state, + result.streamingState, + WsEvent.MessageReactionUpdated(7L, emptyList(), "user", "s"), + "s", + ) + assertTrue( + retracted.state.messages[0] + .reactions + .isEmpty(), + ) + } + + @Test + fun messageReactionForUnknownRowOrOtherSessionIsIgnored() { + val msg = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L) + val state = ChatUiState(currentSessionId = "s", messages = listOf(msg)) + val reactions = listOf(MessageReaction("\uD83D\uDC4D", "agent")) + val unknownRow = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(99L, reactions, "user", "s"), + "s", + ) + assertEquals(state, unknownRow.state) + val otherSession = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageReactionUpdated(7L, reactions, "user", "zzz"), + "s", + ) + assertEquals(state, otherSession.state) + } }