fix(chat): render reasoning card inside bubble and preserve persistence (#691)

* fix(chat): render reasoning card inside bubble and preserve persistence

Move ReasoningCard inside AssistantBubble and preserve reasoningText across REST re-fetches, MessageStart, and buffer flushes.

* style(chat): resolve ktlint unused imports and blank line formatting
This commit is contained in:
M57 authored and GitHub committed 2026-07-23 12:04:18 +03:00
1 parent a5baf41545
commit bc6c8a2e68
6 files changed
+89 -38

No files matched your search

@@ -92,6 +92,7 @@ import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonArray
@@ -389,6 +390,13 @@ private fun AssistantBubble(
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
if (message.reasoningText.isNotBlank()) {
ReasoningCard(
reasoningText = message.reasoningText,
isStreaming = message.isStreaming,
)
Spacer(modifier = Modifier.height(6.dp))
}
SelectionContainer {
MarkdownText(
text = message.content,
@@ -42,6 +42,7 @@ class ChatStreamingController(
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
*/
fun resetStreaming() {
flushReasoning()
streamingBuffer.clear()
thinkingBuffer.clear()
reasoningBuffer.clear()
@@ -69,10 +70,13 @@ class ChatStreamingController(
streamingState.update { state ->
val current = state.streamingMessage
if (current != null) {
val currentReasoning =
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
state.copy(
streamingMessage =
current.copy(
content = currentContent,
reasoningText = currentReasoning,
),
isThinking = false,
)
@@ -82,6 +86,7 @@ class ChatStreamingController(
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
reasoningText = state.reasoningText,
isStreaming = true,
)
state.copy(
@@ -125,15 +130,20 @@ class ChatStreamingController(
val shouldFlush =
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = reasoningBuffer.toString()
lastReasoningFlushMs = now
streamingState.update { state ->
state.copy(
isReasoning = true,
reasoningText = currentContent,
streamingMessage = state.streamingMessage?.copy(reasoningText = currentContent),
)
}
flushReasoning(now)
}
}
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
val currentContent = reasoningBuffer.toString()
if (currentContent.isEmpty()) return
lastReasoningFlushMs = now
streamingState.update { state ->
state.copy(
isReasoning = true,
reasoningText = currentContent,
streamingMessage = state.streamingMessage?.copy(reasoningText = currentContent),
)
}
}
}
@@ -1576,8 +1576,13 @@ class ChatViewModel(
sessionId: String,
messages: List<SessionMessage>,
offset: Int,
): List<ChatMessage> =
messages.mapIndexed { index, msg ->
): List<ChatMessage> {
val existingReasoningMap =
_uiState.value.messages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
return messages.mapIndexed { index, msg ->
val role =
when (msg.role?.lowercase()) {
"user" -> MessageRole.USER
@@ -1592,14 +1597,25 @@ class ChatViewModel(
?.times(1000)
?.toLong()
?: System.currentTimeMillis()
val content = msg.contentText
val reasoning =
if (msg.reasoningText.isNotBlank()) {
msg.reasoningText
} else {
existingReasoningMap[content]?.reasoningText.orEmpty()
}
ChatMessage(
id = "rest-$sessionId-$globalIndex",
role = role,
content = msg.contentText,
content = content,
reasoningText = reasoning,
timestamp = timestamp,
isStreaming = false,
)
}
}
private fun serverMessageIndex(
id: String,
@@ -129,6 +129,7 @@ object ChatWsEventReducer {
ChatMessage(
role = MessageRole.ASSISTANT,
content = "",
reasoningText = streamingState.reasoningText,
isStreaming = true,
)
val effects = mutableListOf<ReducerEffect>()
@@ -148,7 +149,12 @@ object ChatWsEventReducer {
isAgentTyping = true,
)
}
val newStreamingState = StreamingState(streamingMessage = msg)
val newStreamingState =
StreamingState(
streamingMessage = msg,
isReasoning = streamingState.isReasoning,
reasoningText = streamingState.reasoningText,
)
val newState = preState
val sid = newState.currentSessionId
if (orphan != null && sid != null) {
@@ -224,15 +230,21 @@ object ChatWsEventReducer {
event: WsEvent.MessageComplete,
): ReducerResult {
val streaming = streamingState.streamingMessage
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
streamingState.reasoningText
} else {
streaming?.reasoningText ?: ""
}
val msg =
streaming?.copy(
content = event.text,
isStreaming = false,
reasoningText = streamingState.reasoningText,
reasoningText = reasoning,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
reasoningText = streamingState.reasoningText,
reasoningText = reasoning,
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -261,7 +273,13 @@ object ChatWsEventReducer {
state = state,
streamingState = streamingState,
)
val msg = streaming.copy(isStreaming = false, reasoningText = streamingState.reasoningText)
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
streamingState.reasoningText
} else {
streaming.reasoningText
}
val msg = streaming.copy(isStreaming = false, reasoningText = reasoning)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
if (sid != null) {
@@ -300,7 +318,17 @@ object ChatWsEventReducer {
var orphanToPersist: ChatMessage? = null
val newState =
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
val finalized = streamingState.streamingMessage.copy(isStreaming = false)
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
streamingState.reasoningText
} else {
streamingState.streamingMessage.reasoningText
}
val finalized =
streamingState.streamingMessage.copy(
isStreaming = false,
reasoningText = reasoning,
)
orphanToPersist = finalized
state.copy(
messages = state.messages + finalized,
@@ -2,10 +2,8 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -92,16 +90,6 @@ fun ChatMessageList(
val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
// Reasoning card — rendered inline in the same list item so it
// stays visible during AND after streaming
if (isAssistant && message.reasoningText.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
ReasoningCard(
reasoningText = message.reasoningText,
isStreaming = message.isStreaming,
)
}
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
lastAnimatedMessageId != message.id
) {
@@ -127,15 +115,6 @@ fun ChatMessageList(
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
// Reasoning card — rendered inline in the same streaming item
if (streaming.reasoningText.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
ReasoningCard(
reasoningText = streaming.reasoningText,
isStreaming = streaming.isStreaming,
)
}
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,