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"), ) } }