feat(chat): show agent message reactions (#1422)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 23:15:11 +03:00
1 parent 6e14c49df1
commit 0c336ecc4d
14 files changed
+289

No files matched your search

@@ -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<MessageReaction> =
(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<MessageReaction> {
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)
}
}
@@ -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<MessageReaction>
get() = parseMessageReactions(display_metadata)
val tokenCount: Int?
get() {
val prim = token_count as? JsonPrimitive ?: return null
@@ -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
@@ -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<com.m57.hermescontrol.data.model.MessageReaction>,
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. */
@@ -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<MessageReaction> = emptyList(),
)
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
@@ -274,6 +274,7 @@ internal fun mapServerMessages(
tokenCount = tokenCount,
completionId = completionId,
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
reactions = msg.reactions,
),
)
}
@@ -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,
@@ -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,
@@ -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<MessageReaction>,
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),
)
}
}
}
}
@@ -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 &&
@@ -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),
)
}
}
}
}