From bc6c8a2e6862bee1440bc436c5e23da5601d3525 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:04:18 +0300 Subject: [PATCH] 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 --- .../data/model/SessionMessagesResponse.kt | 10 +++++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 8 ++++ .../ui/chat/ChatStreamingController.kt | 28 +++++++++----- .../hermescontrol/ui/chat/ChatViewModel.kt | 22 +++++++++-- .../ui/chat/ChatWsEventReducer.kt | 38 ++++++++++++++++--- .../ui/chat/components/ChatMessageList.kt | 21 ---------- 6 files changed, 89 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index c8fc82f6..5feae4a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -17,6 +17,8 @@ data class SessionMessage( val content: JsonElement? = null, val timestamp: JsonElement? = null, val type: String? = null, + val reasoning: JsonElement? = null, + val reasoning_text: JsonElement? = null, ) { val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content @@ -28,4 +30,12 @@ data class SessionMessage( null -> "" else -> content.toString() } + + val reasoningText: String + get() = + when (val r = reasoning ?: reasoning_text) { + is JsonPrimitive -> r.content + null -> "" + else -> r.toString() + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 48f65762..a586b15b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt index ae39877c..cf0d775a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt @@ -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), + ) } } } 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 a1827d87..67d9d7af 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 @@ -1576,8 +1576,13 @@ class ChatViewModel( sessionId: String, messages: List, offset: Int, - ): List = - messages.mapIndexed { index, msg -> + ): List { + 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, 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 f831e899..57b6e6ba 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 @@ -129,6 +129,7 @@ object ChatWsEventReducer { ChatMessage( role = MessageRole.ASSISTANT, content = "", + reasoningText = streamingState.reasoningText, isStreaming = true, ) val effects = mutableListOf() @@ -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() 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() 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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index 7c3991cb..20a37878 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -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,