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 851ec72a..b0ea9aa1 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 @@ -2929,11 +2929,18 @@ class ChatViewModel( existingReasoningMap[rawContent]?.reasoningText.orEmpty() } - // Reasoning-only assistant row (the gateway's split storage of a - // reasoning turn): stash the trace, skip the empty bubble, and - // attach it to the next assistant message that has content. - if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isNotBlank()) { - pendingReasoning = rowReasoning + // Empty assistant row — two cases stored by the gateway: + // 1. Reasoning-only: thinking-model split storage (content = "", + // reasoning = trace). Stash the trace and fold it into the + // next assistant message that has content (issue #771). + // 2. Tool-call placeholder: non-reasoning models emit content = "" + // with tool_calls metadata and no reasoning. These carry no + // user-visible text and must not render as empty bubbles + // (issue #956). + if (role == MessageRole.ASSISTANT && rawContent.isBlank()) { + if (rowReasoning.isNotBlank()) { + pendingReasoning = rowReasoning + } return@forEachIndexed } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 1145954a..4ded4389 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -103,15 +103,21 @@ internal fun FullBleedAgentMessage( Spacer(modifier = Modifier.height(6.dp)) } - SelectionContainer { - MarkdownText( - text = message.content, - textColor = textColor, - isStreaming = message.isStreaming, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - onImageClick = onImageClick, - ) + // Defense-in-depth: never render an empty prose block (blank bubble + + // lone Copy button). Blank settled rows are tool-call placeholders that + // slipped through upstream mapping; streaming keeps rendering so the + // live cursor survives until the first delta lands. + if (message.content.isNotBlank() || message.isStreaming) { + SelectionContainer { + MarkdownText( + text = message.content, + textColor = textColor, + isStreaming = message.isStreaming, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + onImageClick = onImageClick, + ) + } } // Render inline attachments (mirrors ChatBubble so agent-delivered