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
2026-07-23 12:04:18 +03:00
committed by GitHub
parent a5baf41545
commit bc6c8a2e68
6 changed files with 89 additions and 38 deletions
@@ -17,6 +17,8 @@ data class SessionMessage(
val content: JsonElement? = null, val content: JsonElement? = null,
val timestamp: JsonElement? = null, val timestamp: JsonElement? = null,
val type: String? = null, val type: String? = null,
val reasoning: JsonElement? = null,
val reasoning_text: JsonElement? = null,
) { ) {
val timestampText: String? val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content get() = (timestamp as? JsonPrimitive)?.content
@@ -28,4 +30,12 @@ data class SessionMessage(
null -> "" null -> ""
else -> content.toString() else -> content.toString()
} }
val reasoningText: String
get() =
when (val r = reasoning ?: reasoning_text) {
is JsonPrimitive -> r.content
null -> ""
else -> r.toString()
}
} }
@@ -92,6 +92,7 @@ import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.onColorFor import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
@@ -389,6 +390,13 @@ private fun AssistantBubble(
tonalElevation = 1.dp, tonalElevation = 1.dp,
) { ) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.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 { SelectionContainer {
MarkdownText( MarkdownText(
text = message.content, text = message.content,
@@ -42,6 +42,7 @@ class ChatStreamingController(
* ToolStart, ClarifyRequest, session switches, and interrupt handling. * ToolStart, ClarifyRequest, session switches, and interrupt handling.
*/ */
fun resetStreaming() { fun resetStreaming() {
flushReasoning()
streamingBuffer.clear() streamingBuffer.clear()
thinkingBuffer.clear() thinkingBuffer.clear()
reasoningBuffer.clear() reasoningBuffer.clear()
@@ -69,10 +70,13 @@ class ChatStreamingController(
streamingState.update { state -> streamingState.update { state ->
val current = state.streamingMessage val current = state.streamingMessage
if (current != null) { if (current != null) {
val currentReasoning =
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
state.copy( state.copy(
streamingMessage = streamingMessage =
current.copy( current.copy(
content = currentContent, content = currentContent,
reasoningText = currentReasoning,
), ),
isThinking = false, isThinking = false,
) )
@@ -82,6 +86,7 @@ class ChatStreamingController(
ChatMessage( ChatMessage(
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
content = currentContent, content = currentContent,
reasoningText = state.reasoningText,
isStreaming = true, isStreaming = true,
) )
state.copy( state.copy(
@@ -125,7 +130,13 @@ class ChatStreamingController(
val shouldFlush = val shouldFlush =
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment() (now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
if (shouldFlush) { if (shouldFlush) {
flushReasoning(now)
}
}
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
val currentContent = reasoningBuffer.toString() val currentContent = reasoningBuffer.toString()
if (currentContent.isEmpty()) return
lastReasoningFlushMs = now lastReasoningFlushMs = now
streamingState.update { state -> streamingState.update { state ->
state.copy( state.copy(
@@ -135,5 +146,4 @@ class ChatStreamingController(
) )
} }
} }
}
} }
@@ -1576,8 +1576,13 @@ class ChatViewModel(
sessionId: String, sessionId: String,
messages: List<SessionMessage>, messages: List<SessionMessage>,
offset: Int, offset: Int,
): List<ChatMessage> = ): List<ChatMessage> {
messages.mapIndexed { index, msg -> val existingReasoningMap =
_uiState.value.messages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
return messages.mapIndexed { index, msg ->
val role = val role =
when (msg.role?.lowercase()) { when (msg.role?.lowercase()) {
"user" -> MessageRole.USER "user" -> MessageRole.USER
@@ -1592,14 +1597,25 @@ class ChatViewModel(
?.times(1000) ?.times(1000)
?.toLong() ?.toLong()
?: System.currentTimeMillis() ?: System.currentTimeMillis()
val content = msg.contentText
val reasoning =
if (msg.reasoningText.isNotBlank()) {
msg.reasoningText
} else {
existingReasoningMap[content]?.reasoningText.orEmpty()
}
ChatMessage( ChatMessage(
id = "rest-$sessionId-$globalIndex", id = "rest-$sessionId-$globalIndex",
role = role, role = role,
content = msg.contentText, content = content,
reasoningText = reasoning,
timestamp = timestamp, timestamp = timestamp,
isStreaming = false, isStreaming = false,
) )
} }
}
private fun serverMessageIndex( private fun serverMessageIndex(
id: String, id: String,
@@ -129,6 +129,7 @@ object ChatWsEventReducer {
ChatMessage( ChatMessage(
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
content = "", content = "",
reasoningText = streamingState.reasoningText,
isStreaming = true, isStreaming = true,
) )
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
@@ -148,7 +149,12 @@ object ChatWsEventReducer {
isAgentTyping = true, isAgentTyping = true,
) )
} }
val newStreamingState = StreamingState(streamingMessage = msg) val newStreamingState =
StreamingState(
streamingMessage = msg,
isReasoning = streamingState.isReasoning,
reasoningText = streamingState.reasoningText,
)
val newState = preState val newState = preState
val sid = newState.currentSessionId val sid = newState.currentSessionId
if (orphan != null && sid != null) { if (orphan != null && sid != null) {
@@ -224,15 +230,21 @@ object ChatWsEventReducer {
event: WsEvent.MessageComplete, event: WsEvent.MessageComplete,
): ReducerResult { ): ReducerResult {
val streaming = streamingState.streamingMessage val streaming = streamingState.streamingMessage
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
streamingState.reasoningText
} else {
streaming?.reasoningText ?: ""
}
val msg = val msg =
streaming?.copy( streaming?.copy(
content = event.text, content = event.text,
isStreaming = false, isStreaming = false,
reasoningText = streamingState.reasoningText, reasoningText = reasoning,
) ?: ChatMessage( ) ?: ChatMessage(
role = MessageRole.ASSISTANT, role = MessageRole.ASSISTANT,
content = event.text, content = event.text,
reasoningText = streamingState.reasoningText, reasoningText = reasoning,
) )
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId val sid = state.currentSessionId
@@ -261,7 +273,13 @@ object ChatWsEventReducer {
state = state, state = state,
streamingState = streamingState, 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 effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId val sid = state.currentSessionId
if (sid != null) { if (sid != null) {
@@ -300,7 +318,17 @@ object ChatWsEventReducer {
var orphanToPersist: ChatMessage? = null var orphanToPersist: ChatMessage? = null
val newState = val newState =
if (streamingState.streamingMessage?.content?.isNotEmpty() == true) { 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 orphanToPersist = finalized
state.copy( state.copy(
messages = state.messages + finalized, 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.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -92,16 +90,6 @@ fun ChatMessageList(
val isLastMessage = index == messages.lastIndex val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT 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 && if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
lastAnimatedMessageId != message.id lastAnimatedMessageId != message.id
) { ) {
@@ -127,15 +115,6 @@ fun ChatMessageList(
// Streaming message // Streaming message
streamingMessage?.let { streaming -> streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") { 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) { if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect( StreamingBubbleWithTypingEffect(
streaming = streaming, streaming = streaming,