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.
*/
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() {
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) {
@@ -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<ReducerEffect>()
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<ReducerEffect>()
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(
@@ -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"),
)
}
}