From 8c2d4216a797c54264156755e9fb0de6d6654158 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:21:43 +0300 Subject: [PATCH] feat(chat): display completion timestamp beside copy button on assistant message (#1053) --- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 5 +++ .../hermescontrol/ui/chat/ChatViewModel.kt | 6 ++- .../ui/chat/ChatWsEventReducer.kt | 16 ++++++- .../chat/fullbleed/FullBleedAgentMessage.kt | 42 +++++++++++++------ 4 files changed, 53 insertions(+), 16 deletions(-) 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 a437b894..474a8340 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 @@ -66,6 +66,11 @@ data class ChatMessage( * them as centered timeline chips instead of user bubbles. */ val displayKind: String? = null, + /** + * Timestamp (ms) when the assistant message finished streaming. + * When present, rendered at the bottom beside the copy button. + */ + val finishTimestamp: Long? = null, ) /** 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 0ded1a5e..c64e3a16 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 @@ -2665,7 +2665,11 @@ class ChatViewModel( private fun sealStreamingMessageIfAny() { val streaming = _streamingState.value.streamingMessage ?: return if (streaming.content.isBlank() && streaming.reasoningText.isBlank()) return - val finalized = streaming.copy(isStreaming = false) + val finalized = + streaming.copy( + isStreaming = false, + finishTimestamp = System.currentTimeMillis(), + ) _uiState.update { it.copy(messages = (it.messages + finalized).dedupeById()) } val sid = _uiState.value.currentSessionId if (sid != null) { 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 e9e1b4ce..0ebf2a0c 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 @@ -206,7 +206,11 @@ object ChatWsEventReducer { var orphan: ChatMessage? = null val preState = if (streamingState.streamingMessage?.content?.isNotEmpty() == true) { - val finalized = streamingState.streamingMessage.copy(isStreaming = false) + val finalized = + streamingState.streamingMessage.copy( + isStreaming = false, + finishTimestamp = System.currentTimeMillis(), + ) orphan = finalized state.copy( messages = state.messages.upsertById(finalized), @@ -369,10 +373,12 @@ object ChatWsEventReducer { content = text, isStreaming = false, reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), ) ?: ChatMessage( role = MessageRole.ASSISTANT, content = text, reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), ) val effects = mutableListOf() val sid = state.currentSessionId @@ -418,7 +424,12 @@ object ChatWsEventReducer { } else { streaming.reasoningText } - val msg = streaming.copy(isStreaming = false, reasoningText = reasoning) + val msg = + streaming.copy( + isStreaming = false, + reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), + ) val effects = mutableListOf() val sid = state.currentSessionId if (sid != null) { @@ -481,6 +492,7 @@ object ChatWsEventReducer { streamingState.streamingMessage.copy( isStreaming = false, reasoningText = reasoning, + finishTimestamp = System.currentTimeMillis(), ) orphanToPersist = finalized state.copy( 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 4ded4389..8acdba59 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 @@ -140,20 +140,36 @@ internal fun FullBleedAgentMessage( } if (!message.isStreaming) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content))) - } - copied = true - }, - modifier = Modifier.size(28.dp).testTag("fullbleed_copy"), + Row( + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content))) + } + copied = true + }, + modifier = Modifier.size(28.dp).testTag("fullbleed_copy"), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + val finishTime = message.finishTimestamp ?: message.timestamp + Text( + text = + com.m57.hermescontrol.ui.chat.formatTimestamp( + finishTime, + DateFormat.is24HourFormat(LocalContext.current), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.testTag("fullbleed_finish_time"), ) } }