feat(chat): display completion timestamp beside copy button on assistant message (#1053)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 20:21:43 +03:00
1 parent 5a79da96f7
commit 8c2d4216a7
4 files changed
+53 -16

No files matched your search

@@ -66,6 +66,11 @@ data class ChatMessage(
* them as centered timeline chips instead of user bubbles. * them as centered timeline chips instead of user bubbles.
*/ */
val displayKind: String? = null, 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,
) )
/** /**
@@ -2665,7 +2665,11 @@ class ChatViewModel(
private fun sealStreamingMessageIfAny() { private fun sealStreamingMessageIfAny() {
val streaming = _streamingState.value.streamingMessage ?: return val streaming = _streamingState.value.streamingMessage ?: return
if (streaming.content.isBlank() && streaming.reasoningText.isBlank()) 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()) } _uiState.update { it.copy(messages = (it.messages + finalized).dedupeById()) }
val sid = _uiState.value.currentSessionId val sid = _uiState.value.currentSessionId
if (sid != null) { if (sid != null) {
@@ -206,7 +206,11 @@ object ChatWsEventReducer {
var orphan: ChatMessage? = null var orphan: ChatMessage? = null
val preState = val preState =
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) { 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 orphan = finalized
state.copy( state.copy(
messages = state.messages.upsertById(finalized), messages = state.messages.upsertById(finalized),
@@ -369,10 +373,12 @@ object ChatWsEventReducer {
content = text, content = text,
isStreaming = false, isStreaming = false,
reasoningText = reasoning, reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
) ?: ChatMessage( ) ?: ChatMessage(
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
content = text, content = text,
reasoningText = reasoning, reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
) )
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId val sid = state.currentSessionId
@@ -418,7 +424,12 @@ object ChatWsEventReducer {
} else { } else {
streaming.reasoningText streaming.reasoningText
} }
val msg = streaming.copy(isStreaming = false, reasoningText = reasoning) val msg =
streaming.copy(
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
)
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId val sid = state.currentSessionId
if (sid != null) { if (sid != null) {
@@ -481,6 +492,7 @@ object ChatWsEventReducer {
streamingState.streamingMessage.copy( streamingState.streamingMessage.copy(
isStreaming = false, isStreaming = false,
reasoningText = reasoning, reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
) )
orphanToPersist = finalized orphanToPersist = finalized
state.copy( state.copy(
@@ -140,20 +140,36 @@ internal fun FullBleedAgentMessage(
} }
if (!message.isStreaming) { if (!message.isStreaming) {
IconButton( Row(
onClick = { verticalAlignment = Alignment.CenterVertically,
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
}
copied = true
},
modifier = Modifier.size(28.dp).testTag("fullbleed_copy"),
) { ) {
Icon( IconButton(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, onClick = {
contentDescription = stringResource(R.string.content_desc_copy), scope.launch {
modifier = Modifier.size(14.dp), clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
tint = MaterialTheme.colorScheme.onSurfaceVariant, }
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"),
) )
} }
} }