feat(chat): show agent message reactions (#1422)
This commit is contained in:
14 files changed
+289
No files matched your search
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user