feat(chat): full-bleed agent replies behind a chat style setting (issue #866) (#878)

* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866)

* feat(settings): chat style toggle in Appearance (issue #866)

* feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866)

* feat(chat): full-bleed turn grouping (issue #866)

* feat(chat): full-bleed agent message renderer (issue #866)

* feat(chat): full-bleed compact tool rows + system events (issue #866)

* feat(chat): full-bleed chat list renderer + streaming (issue #866)

* feat(chat): wire full-bleed renderer behind chat style setting (issue #866)

* test(theme): full-bleed contrast gate across all presets (issue #866)

Gate caught NORD light primary/background < 3:1 — header role label
switched to onSurfaceVariant (meta token, correct for header + passes
in every preset; NORD's pastel Frost accent can't reach 3:1 on light).

* test(chat): full-bleed renderer coverage (issue #866)

* style(chat): ktlint formatting pass on full-bleed renderer (issue #866)

* fix(chat): eagerly capture per-item state in full-bleed list (issue #866)

LazyColumn item{} lambdas run at composition time, so loop locals read
inside them saw final values: turn header never rendered (firstProseSeen
always true at compose) and milestones misindexed. Capture showTurnHeader
+ milestone as immutable vals during the DSL loop. Caught by fresh-context
review (decompiled Compose foundation to confirm item composition timing).

* test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866)

refreshSettings()/loadSettings() now read AuthManager.getChatStyle();
the four test files that mockkObject(AuthManager) with explicit stubs
had no stub for the new method, so mockk fell through to the real
object and threw 'AuthManager not initialized'. Full suite: 953/953
passing, same shape as pristine origin/main.

* feat(settings): move chat style toggle from Appearance to Chat section

* feat(chat): hoist reasoning above tool rows in full-bleed turns

Tool rows stream in before the answer message, so the turn rendered
tools above the reasoning card. The turn's reasoning now renders at
the top of the agent turn, with tool rows and prose underneath.

Streaming is folded into the turn model (groupIntoTurnsWithStreaming)
so the hoist works live while the answer is still streaming, and the
detached streaming item is gone (which also fixed the duplicate
turn-header quirk for streaming continuations).

* feat(chat): cap reasoning card height + long-press to copy

Expanded trace caps at 40% of the screen with internal scroll (Show
full lifts the cap); long-press anywhere copies the reasoning.

* feat(chat): blinking typing caret while streaming

* feat(chat): make full-bleed the default chat style

* feat(chat): remove thinking dots, show agent header above reasoning

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-12 00:22:07 +03:00
1 parent 758de04917
commit a59c8c6a4f
26 files changed
+1301 -52

No files matched your search

@@ -0,0 +1,135 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus
import io.mockk.mockk
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented Compose UI tests for the full-bleed chat renderer (issue #866).
*
* Validates the parallel renderer's hierarchy contract:
* - user messages KEEP the bubble (universal anchor)
* - assistant prose renders full-bleed (no bubble), one turn header per turn
* - tool rows render as distinct compact rows, NOT full-bleed prose
* - streaming + clarify still render
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class FullBleedChatListTest {
@get:Rule
val composeTestRule = createComposeRule()
private fun msg(
id: String,
role: MessageRole,
content: String = "content-$id",
toolStatus: ToolStatus? = null,
isStreaming: Boolean = false,
) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming)
private fun render(
messages: List<ChatMessage>,
streamingMessage: ChatMessage? = null,
clarify: Boolean = false,
) {
composeTestRule.setContent {
FullBleedChatList(
messages = messages,
streamingMessage = streamingMessage,
isSearchActive = false,
searchQuery = "",
currentSearchMatchIndex = -1,
searchMatchIndices = emptyList(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoading = false,
isLoadingOlder = false,
isDark = false,
listState = LazyListState(),
lastAnimatedMessageId = null,
onLastAnimatedMessageIdChange = {},
viewModel = mockk<ChatViewModel>(relaxed = true),
clarifyRequest =
if (clarify) {
com.m57.hermescontrol.ui.chat.ClarifyUi(
text = "pick",
options = listOf("a"),
)
} else {
null
},
)
}
}
@Test
fun userMessages_keepBubble_agentProse_isFullBleed() {
render(
listOf(
msg("u1", MessageRole.USER),
msg("a1", MessageRole.ASSISTANT),
),
)
composeTestRule.onNodeWithTag("chat_bubble_user").assertIsDisplayed()
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
}
@Test
fun agentProse_hasNoBubbleContainer() {
render(listOf(msg("a1", MessageRole.ASSISTANT)))
composeTestRule.onNodeWithTag("chat_bubble_assistant").assertDoesNotExist()
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
}
@Test
fun toolRows_renderCompact_notFullBleed() {
render(
listOf(
msg("a1", MessageRole.ASSISTANT),
msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED),
),
)
composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed()
// exactly one full-bleed message (the prose), not the tool row
composeTestRule.onAllNodesWithTag("fullbleed_agent_message").assertCountEquals(1)
}
@Test
fun turnHeader_renderedOncePerAgentTurn() {
render(
listOf(
msg("a1", MessageRole.ASSISTANT),
msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED),
msg("a2", MessageRole.ASSISTANT),
msg("u2", MessageRole.USER),
msg("a3", MessageRole.ASSISTANT),
),
)
// Two agent turns (a1..a2, a3) -> two turn headers (one per turn).
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2)
}
@Test
fun streaming_and_clarify_stillRender() {
render(
messages = listOf(msg("u1", MessageRole.USER)),
streamingMessage = msg("s1", MessageRole.ASSISTANT, content = "streaming", isStreaming = true),
clarify = true,
)
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed()
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.serialization.Serializable
@@ -21,6 +22,9 @@ data class ServerStoreState(
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
// Chat rendering style (issue #866): full-bleed renderer (default) or the
// legacy bubble renderer.
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
// App display language. "system" = follow device locale; otherwise a BCP-47
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
val appLanguage: String = "system",
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineScope
@@ -74,6 +75,9 @@ object AuthManager {
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
private val _chatStyleFlow = MutableStateFlow<ChatStyle>(ChatStyle.FULL_BLEED)
val chatStyleFlow: StateFlow<ChatStyle> = _chatStyleFlow.asStateFlow()
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
@@ -180,6 +184,7 @@ object AuthManager {
_themePreferenceFlow.value = state.themePreference
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_chatStyleFlow.value = state.chatStyle
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
}
@@ -584,6 +589,15 @@ object AuthManager {
fun getThemePreset(): ThemePreset = serverStore.getLatestState().themePreset
// ── Chat style (issue #866) ──────────────────────────────────────────
fun getChatStyle(): ChatStyle = serverStore.getLatestState().chatStyle
fun setChatStyle(style: ChatStyle) {
_chatStyleFlow.value = style
serverStore.update { it.copy(chatStyle = style) }
}
fun setThemePreset(preset: ThemePreset) {
serverStore.update { it.copy(themePreset = preset) }
}
@@ -0,0 +1,11 @@
package com.m57.hermescontrol.theme
/**
* Chat message rendering style (issue #866). App-local preference,
* persisted in [com.m57.hermescontrol.data.config.ServerStoreState].
*
* BUBBLES — legacy bubble renderer (default, unchanged behavior).
* FULL_BLEED — agent prose renders directly on the background with turn
* headers; user messages keep bubbles (parallel renderer).
*/
enum class ChatStyle { BUBBLES, FULL_BLEED }
@@ -486,7 +486,7 @@ private fun SelfImprovementReviewCard(
}
@Composable
private fun SystemBubble(
internal fun SystemBubble(
message: ChatMessage,
onRespondApproval: (String) -> Unit = {},
modifier: Modifier = Modifier,
@@ -616,7 +616,7 @@ private fun buildHighlightedString(
* Images are displayed as thumbnails; other files show a compact card.
*/
@Composable
private fun InlineAttachment(
internal fun InlineAttachment(
attachment: Attachment,
textColor: Color,
onOpen: (Attachment) -> Unit,
@@ -81,6 +81,7 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
@@ -97,6 +98,7 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
import com.m57.hermescontrol.ui.common.ActionProgressDialog
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
@@ -553,32 +555,8 @@ fun ChatScreen(
.weight(1f)
.fillMaxWidth(),
) {
ChatMessageList(
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
thinkingText = streamingState.thinkingText,
isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isDark = isDark,
listState = listState,
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onDismissClarify = viewModel::dismissClarify,
onSaveAttachment = { attachment ->
if (!canStartAttachmentSave(pendingSavePath, state.savingAttachmentPath)) {
return@ChatMessageList
}
val onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = { attachment ->
if (canStartAttachmentSave(pendingSavePath, state.savingAttachmentPath)) {
pendingSavePath = viewModel.gatewayPathFor(attachment)
pendingSaveName = attachment.name
pendingSaveMimeType = attachment.mimeType
@@ -599,10 +577,62 @@ fun ChatScreen(
)
},
)
},
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
onImageClick = { viewingImage = it },
)
}
}
when (state.chatStyle) {
ChatStyle.BUBBLES ->
ChatMessageList(
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isDark = isDark,
listState = listState,
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onDismissClarify = viewModel::dismissClarify,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
onImageClick = { viewingImage = it },
)
ChatStyle.FULL_BLEED ->
FullBleedChatList(
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isDark = isDark,
listState = listState,
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onDismissClarify = viewModel::dismissClarify,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
onImageClick = { viewingImage = it },
)
}
// Loading overlay
ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null)
@@ -33,6 +33,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -273,6 +274,9 @@ data class ChatUiState(
// Cached settings
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
// Chat rendering style (issue #866), cached like the typing-effect
// settings so ChatScreen can pick the renderer without a store read.
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
// Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(),
// Per-command usage counts for the slash-autocomplete ranking (issue
@@ -1719,6 +1723,7 @@ class ChatViewModel(
state.copy(
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
chatStyle = AuthManager.getChatStyle(),
)
}
}
@@ -34,8 +34,6 @@ import com.m57.hermescontrol.ui.common.EmptyState
fun ChatMessageList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
thinkingText: String,
isSearchActive: Boolean,
searchQuery: String,
currentSearchMatchIndex: Int,
@@ -155,13 +153,6 @@ fun ChatMessageList(
}
}
// Typing indicator — bouncing dots
if (isThinking) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
@@ -27,10 +28,20 @@ fun StreamingBubbleWithTypingEffect(
onAnimationComplete: () -> Unit = {},
) {
var visibleWordCount by remember { mutableIntStateOf(0) }
var caretVisible by remember { mutableStateOf(true) }
val currentContent = rememberUpdatedState(streaming.content)
val currentIsStreaming = rememberUpdatedState(streaming.isStreaming)
val currentDelayMs = rememberUpdatedState(typingDelayMs)
// Blinking typing caret while the message is still streaming.
LaunchedEffect(Unit) {
while (currentIsStreaming.value) {
delay(400)
caretVisible = !caretVisible
}
caretVisible = false
}
// Timer that ticks at the configured delay, incrementing the visible word
// count each tick. Stops ticking when streaming ends, then shows all words.
// Optimized: split is only called when waiting for new content, not per tick.
@@ -63,7 +74,9 @@ fun StreamingBubbleWithTypingEffect(
} else {
visibleWordCount.coerceIn(0, words.size)
}
val displayText = words.take(visibleCount.coerceAtLeast(1)).joinToString(" ")
val displayText =
words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") +
if (caretVisible && currentIsStreaming.value) "▍" else ""
ChatBubble(
message = streaming.copy(content = displayText),
@@ -13,6 +13,8 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -23,12 +25,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
@@ -58,8 +62,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -67,8 +73,10 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.CodeComment
import com.m57.hermescontrol.theme.CodeKeyword
import com.m57.hermescontrol.theme.CodeNumber
@@ -79,38 +87,64 @@ import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import kotlinx.coroutines.delay
// ── ReasoningCard ─────────────────────────────────────────────────────────
/**
* Dark collapsible card showing the assistant's reasoning trace
* Collapsible card showing the assistant's reasoning trace
* (reasoning-model thinking steps) before the final answer.
*
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
* Expanded: full reasoning text in monospace bodySmall.
* Streaming: pulsing indicator at bottom while [isStreaming].
* Expanded: reasoning text in monospace bodySmall, capped at ~40% of the
* screen height with internal scroll; "Show full" lifts the cap.
* Long-press anywhere: copies the reasoning trace to the clipboard.
* Streaming: pulsing indicator at the bottom while [isStreaming].
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReasoningCard(
reasoningText: String,
isStreaming: Boolean = false,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
var fullHeight by remember { mutableStateOf(false) }
var copied by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp }
// Copy feedback: briefly show "Copied" then revert
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
Card(
modifier =
modifier
.fillMaxWidth()
.then(if (!isStreaming) Modifier.animateContentSize() else Modifier)
.combinedClickable(
onClick = { expanded = !expanded },
onLongClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return@combinedClickable
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
copied = true
},
)
.testTag("reasoning_card"),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
),
shape = RoundedCornerShape(12.dp),
onClick = { expanded = !expanded },
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -125,6 +159,14 @@ fun ReasoningCard(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (copied) {
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(R.string.reasoning_copied),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
@@ -134,15 +176,31 @@ fun ReasoningCard(
}
AnimatedVisibility(visible = expanded) {
Column {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Column(
modifier =
Modifier
.heightIn(max = if (fullHeight) Dp.Unspecified else capHeight)
.verticalScroll(scrollState),
) {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (isStreaming) {
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
}
if (fullHeight) {
TextButton(onClick = { fullHeight = false }) {
Text(stringResource(R.string.reasoning_show_less))
}
} else if (scrollState.maxValue > 0) {
TextButton(onClick = { fullHeight = true }) {
Text(stringResource(R.string.reasoning_show_full))
}
}
}
}
}
@@ -0,0 +1,193 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import android.content.ClipData
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.InlineAttachment
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Full-bleed renderer for ONE agent (assistant) message (issue #866).
*
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
* directly on the background — no bubble container, no width cap — with a
* turn header (role + time) and a trailing copy affordance. User messages
* keep their bubbles; this composable is only used for ASSISTANT messages.
*
* [showTurnHeader] renders the role/time header — true only for the first
* prose entry of an agent turn so multi-message turns don't repeat it.
*/
@Composable
internal fun FullBleedAgentMessage(
message: ChatMessage,
showTurnHeader: Boolean,
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
showReasoning: Boolean = true,
onOpenAttachment: (Attachment) -> Unit = {},
onSaveAttachment: (Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
canSaveAttachment: Boolean = true,
onImageClick: (ImageViewerModel) -> Unit = {},
modifier: Modifier = Modifier,
) {
val textColor = MaterialTheme.colorScheme.onSurface
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
// Copy feedback: briefly show ✓ then revert
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.testTag("fullbleed_agent_message"),
) {
if (showTurnHeader) {
AssistantTurnHeader(message.timestamp)
}
if (showReasoning && message.reasoningText.isNotBlank()) {
ReasoningCard(
reasoningText = message.reasoningText,
isStreaming = message.isStreaming,
)
Spacer(modifier = Modifier.height(6.dp))
}
SelectionContainer {
MarkdownText(
text = message.content,
textColor = textColor,
isStreaming = message.isStreaming,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
}
// Render inline attachments (mirrors ChatBubble so agent-delivered
// media — images, files — shows in full-bleed mode too).
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = textColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
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"),
) {
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,
)
}
}
}
}
/**
* Agent turn header — "Agent · <time>". Shared by the prose message and the
* hoisted reasoning block so both can lead with the assistant identity and
* timestamp.
*/
@Composable
internal fun AssistantTurnHeader(
timestamp: Long,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(bottom = 4.dp).testTag("fullbleed_agent_header"),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.fullbleed_role_agent),
style =
MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
),
// Meta color (not primary): Nord's light mode reuses its
// pastel Frost accent as primary, which fails >= 3:1 on a
// light background. The header is metadata, so the dimmed
// meta token is both more correct and gate-compliant in
// every preset.
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
com.m57.hermescontrol.ui.chat.formatTimestamp(
timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,296 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import com.m57.hermescontrol.ui.chat.toolCallMilestones
import com.m57.hermescontrol.ui.common.EmptyState
/**
* The chat message list for FULL-BLEED style (issue #866).
*
* Parallel renderer to [com.m57.hermescontrol.ui.chat.components.ChatMessageList]:
* user messages keep their bubble (the universal anchor), agent turns render
* full-bleed with a turn header, and tool rows / system events render as
* distinct compact cards. Spacing contract:
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
* - inter-turn: 12.dp bottom padding after each turn's last item
*
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
* LAZILY at item-composition time, not during this DSL-building loop. Loop
* locals that are read inside item lambdas must be captured as immutable
* vals FIRST (eagerly), or every item sees the loop's final value — the
* turn header would never render and milestones would be misindexed.
*/
@Composable
fun FullBleedChatList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isSearchActive: Boolean,
searchQuery: String,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
maxToolCallsPerTurn: Int? = null,
isLoading: Boolean,
isLoadingOlder: Boolean,
isDark: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
onImageClick: (ImageViewerModel) -> Unit = {},
) {
if (messages.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
EmptyState(
title = stringResource(R.string.chat_empty_title),
subtitle = stringResource(R.string.chat_empty_subtitle),
)
}
} else {
val toolMilestones = toolCallMilestones(messages)
val turns =
remember(messages, streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingMessage)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
var entryIndex = 0
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
// Eager captures: item lambda reads these at
// composition time (lazy), so capture now.
val userMessage = turn.message
val milestone = toolMilestones[entryIndex]
item(key = "user-${userMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
isDark = isDark,
searchQuery = searchQuery,
isCurrentMatch =
isCurrentMatchFor(
messages,
userMessage.id,
isSearchActive,
currentSearchMatchIndex,
searchMatchIndices,
),
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
onImageClick = onImageClick,
)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
entryIndex++
}
is ChatTurn.Agent -> {
var firstProseSeen = false
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
// matching prose entry renders without its own card.
val turnReasoning =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.firstOrNull { it.message.reasoningText.isNotBlank() }
if (turnReasoning != null) {
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
// Lead the turn with the assistant identity
// and timestamp, then the thinking block.
AssistantTurnHeader(timestamp = reasoning.timestamp)
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
)
}
}
}
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val showTurnHeader = !firstProseSeen && turnReasoning == null
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
val milestone = toolMilestones[entryIndex]
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
showTurnHeader = showTurnHeader,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
showTurnHeader = showTurnHeader,
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch =
isCurrentMatchFor(
messages,
proseMessage.id,
isSearchActive,
currentSearchMatchIndex,
searchMatchIndices,
),
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
firstProseSeen = true
entryIndex++
}
is AgentEntry.ToolRow -> {
val toolMessage = entry.message
val milestone = toolMilestones[entryIndex]
item(key = "tool-${toolMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
entryIndex++
}
is AgentEntry.SystemEvent -> {
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
}
}
entryIndex++
}
}
}
}
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
ClarifyBubble(
text = clarifyRequest.text,
options = clarifyRequest.options,
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
onDismiss = { onDismissClarify?.invoke() },
)
}
}
}
}
}
@Composable
private fun renderChatBubble(
message: ChatMessage,
isDark: Boolean,
searchQuery: String,
isCurrentMatch: Boolean,
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
savingAttachmentPath: String?,
onImageClick: (ImageViewerModel) -> Unit,
) {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onOpenAttachment = onOpenAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
private fun isCurrentMatchFor(
messages: List<ChatMessage>,
messageId: String,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
): Boolean {
if (!isSearchActive || currentSearchMatchIndex < 0 || currentSearchMatchIndex >= searchMatchIndices.size) {
return false
}
val index = messages.indexOfFirst { it.id == messageId }
if (index < 0) return false
return searchMatchIndices[currentSearchMatchIndex] == index
}
@@ -0,0 +1,96 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import com.m57.hermescontrol.ui.chat.ChatMessage
import kotlinx.coroutines.delay
/**
* Wraps a streaming [FullBleedAgentMessage] with a word-by-word typing reveal
* effect (full-bleed counterpart to
* [com.m57.hermescontrol.ui.chat.components.StreamingBubbleWithTypingEffect]).
*
* Shows words one at a time at [typingDelayMs] intervals while the message is
* still streaming. When streaming completes the full text is shown
* immediately. The underlying [ChatMessage.content] in state is never
* modified — this is a display-only transformation.
*
* Deliberately a separate wrapper (not a genericized version of the bubble
* one): the two renderers are parallel code paths by design (issue #866),
* and the wrappers die together when the bubble path is removed.
*/
@Composable
internal fun StreamingFullBleedWithTypingEffect(
streaming: ChatMessage,
typingDelayMs: Int,
isDark: Boolean,
showTurnHeader: Boolean = true,
showReasoning: Boolean = true,
onAnimationComplete: () -> Unit = {},
) {
var visibleWordCount by remember { mutableIntStateOf(0) }
var caretVisible by remember { mutableStateOf(true) }
val currentContent = rememberUpdatedState(streaming.content)
val currentIsStreaming = rememberUpdatedState(streaming.isStreaming)
val currentDelayMs = rememberUpdatedState(typingDelayMs)
// Blinking typing caret while the message is still streaming.
LaunchedEffect(Unit) {
while (currentIsStreaming.value) {
delay(400)
caretVisible = !caretVisible
}
caretVisible = false
}
// Timer that ticks at the configured delay, incrementing the visible word
// count each tick. Stops ticking when streaming ends, then shows all words.
// Optimized: split is only called when waiting for new content, not per tick.
LaunchedEffect(Unit) {
var wordCount = 0
while (true) {
if (visibleWordCount < wordCount) {
delay(currentDelayMs.value.toLong())
visibleWordCount++
} else {
if (!currentIsStreaming.value) {
onAnimationComplete()
break
}
// Only split when we need to check for new content arriving
val words = currentContent.value.split(" ")
wordCount = words.size
if (visibleWordCount < wordCount) continue
delay(100)
}
}
visibleWordCount = Int.MAX_VALUE
}
// Derive display text from the latest full content at each recomposition
val words = streaming.content.split(" ")
val visibleCount =
if (visibleWordCount >= Int.MAX_VALUE / 2) {
words.size
} else {
visibleWordCount.coerceIn(0, words.size)
}
val displayText =
words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") +
if (caretVisible && currentIsStreaming.value) "▍" else ""
FullBleedAgentMessage(
message = streaming.copy(content = displayText),
showTurnHeader = showTurnHeader,
showReasoning = showReasoning,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
@@ -0,0 +1,57 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.SystemBubble
import com.m57.hermescontrol.ui.chat.ToolBubble
/**
* Tool-row treatment inside the full-bleed renderer (issue #866).
*
* ToolBubble is ALREADY a compact dimmed card (surfaceContainerHigh, 8dp
* radius, collapsed summary with expand/copy/raw-json/risk-chip) — distinct
* from agent prose by design. In full-bleed mode we reuse it verbatim so the
* verified tool-rendering logic is never duplicated, adding only a 16dp
* start indent so tool rows align with the full-bleed prose margin.
*/
@Composable
internal fun FullBleedToolRow(
message: ChatMessage,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp)
.testTag("fullbleed_tool_row"),
) {
ToolBubble(message)
}
}
/**
* System-event treatment inside the full-bleed renderer (issue #866).
*
* Reuses [SystemBubble] verbatim — it already renders as a centered, dimmed,
* italic caption (with approval buttons and the Self-improvement review card
* handling intact), which keeps system events visually distinct from prose.
*/
@Composable
internal fun FullBleedSystemEvent(
message: ChatMessage,
onRespondApproval: (String) -> Unit,
modifier: Modifier = Modifier,
) {
SystemBubble(
message = message,
onRespondApproval = onRespondApproval,
modifier = modifier.testTag("fullbleed_system_event"),
)
}
@@ -0,0 +1,90 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
/**
* Turn model for the full-bleed chat renderer (issue #866).
*
* A turn is a unit of conversation for spacing + header purposes:
* - [ChatTurn.User]: one user message — always its own turn (bubble anchor).
* - [ChatTurn.Agent]: everything between user messages — assistant prose,
* tool rows, and system events, in original order.
*/
sealed interface ChatTurn {
/** User message — always its own turn (bubble anchor). */
data class User(val message: ChatMessage) : ChatTurn
/** One agent turn: prose, tool rows, and system events in order. */
data class Agent(val entries: List<AgentEntry>) : ChatTurn
}
sealed interface AgentEntry {
data class Prose(val message: ChatMessage) : AgentEntry
data class ToolRow(val message: ChatMessage) : AgentEntry
data class SystemEvent(val message: ChatMessage) : AgentEntry
}
/**
* Split a flat message list into turns for the full-bleed renderer.
*
* Each USER message closes the current agent turn (if any) and opens a User
* turn; all non-user messages belong to the surrounding agent turn. A new
* agent turn starts after each user turn or at the start of the list.
*/
fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
val turns = mutableListOf<ChatTurn>()
val agentEntries = mutableListOf<AgentEntry>()
fun flushAgent() {
if (agentEntries.isNotEmpty()) {
turns += ChatTurn.Agent(agentEntries.toList())
agentEntries.clear()
}
}
messages.forEach { message ->
when (message.role) {
MessageRole.USER -> {
flushAgent()
turns += ChatTurn.User(message)
}
MessageRole.ASSISTANT -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM -> agentEntries += AgentEntry.SystemEvent(message)
}
}
flushAgent()
return turns
}
/**
* Like [groupIntoTurns] but folds the in-flight streaming assistant message
* into the current agent turn, so it renders as part of the turn (reasoning
* hoist, turn headers, spacing) instead of as a detached tail item.
*
* Defensive: if the streaming message's id is already present in [messages]
* (commit race — the message landed while the UI still held the streaming
* copy), it is not appended again; a duplicate prose entry would produce a
* LazyColumn duplicate-key crash.
*/
fun groupIntoTurnsWithStreaming(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
): List<ChatTurn> {
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) {
return groupIntoTurns(messages)
}
val turns = groupIntoTurns(messages).toMutableList()
val prose = AgentEntry.Prose(streamingMessage)
val last = turns.lastOrNull()
if (last is ChatTurn.Agent) {
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose)
} else {
turns += ChatTurn.Agent(listOf(prose))
}
return turns
}
@@ -163,6 +163,8 @@ internal fun SettingsChatPage(
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
chatStyle = state.chatStyle,
onChatStyleChange = viewModel::onChatStyleChange,
)
}
}
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineDispatcher
@@ -35,6 +36,7 @@ data class SettingsUiState(
val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30,
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
@@ -75,6 +77,7 @@ class SettingsViewModel(
val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatStyle = AuthManager.getChatStyle()
val profiles = AuthManager.getConnectionProfiles()
val appLanguage = AuthManager.getAppLanguage()
val renameProfileName =
@@ -94,6 +97,7 @@ class SettingsViewModel(
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs,
chatStyle = chatStyle,
profiles = profiles,
selectedProfileId = selectedId,
renameProfileName = renameProfileName,
@@ -294,6 +298,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectDelayMs(delayMs)
}
fun onChatStyleChange(style: ChatStyle) {
_uiState.update { it.copy(chatStyle = style, isSaved = false) }
AuthManager.setChatStyle(style)
}
/** Clear all auth credentials — logs out and returns to landing screen. */
fun logout() {
AuthManager.setToken(null)
@@ -320,6 +329,7 @@ class SettingsViewModel(
AuthManager.setThemePreset(state.themePreset)
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
AuthManager.setChatStyle(state.chatStyle)
ApiClient.rebuild()
viewModelScope.launch(ioDispatcher) {
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.settings.SectionCard
@@ -234,8 +235,48 @@ internal fun ChatSection(
onTypingEffectEnabledChange: (Boolean) -> Unit,
typingEffectDelayMs: Int,
onTypingEffectDelayMsChange: (Int) -> Unit,
chatStyle: ChatStyle,
onChatStyleChange: (ChatStyle) -> Unit,
) {
SectionCard {
// Chat rendering style (issue #866): bubble renderer (default) or the
// parallel full-bleed renderer. Kept at the top of the section — it's
// the primary chat-behavior choice.
Text(
text = stringResource(R.string.settings_item_chat_style),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.settings_desc_chat_style),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ChatStyle.entries.forEachIndexed { index, style ->
SegmentedButton(
selected = chatStyle == style,
onClick = { onChatStyleChange(style) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = ChatStyle.entries.size,
),
) {
Text(
when (style) {
ChatStyle.BUBBLES -> stringResource(R.string.chat_style_bubbles)
ChatStyle.FULL_BLEED -> stringResource(R.string.chat_style_full_bleed)
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
+8
View File
@@ -143,6 +143,11 @@
<string name="settings_sec_chat">채팅</string>
<string name="settings_item_typing_effect">타이핑 효과</string>
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
<string name="settings_item_chat_style">채팅 스타일</string>
<string name="settings_desc_chat_style">에이전트 응답 표시 방식</string>
<string name="chat_style_bubbles">버블</string>
<string name="chat_style_full_bleed">전체 폭</string>
<string name="fullbleed_role_agent">어시스턴트</string>
<string name="settings_item_typing_delay">지연: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
@@ -724,6 +729,9 @@
<string name="analytics_output_tokens">출력</string>
<string name="analytics_cache_read">캐시 읽기</string>
<string name="analytics_reasoning">추론</string>
<string name="reasoning_copied">복사됨</string>
<string name="reasoning_show_full">전체 보기</string>
<string name="reasoning_show_less">줄이기</string>
<string name="analytics_daily_cost">일일 예상 비용</string>
<string name="analytics_loading_usage">사용 분석 불러오는 중…</string>
<string name="analytics_no_cost_data">이 기간에 기록된 비용이 없습니다.</string>
+8
View File
@@ -163,6 +163,11 @@
<string name="settings_sec_chat">Chat</string>
<string name="settings_item_typing_effect">Typing Effect</string>
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
<string name="settings_item_chat_style">Chat style</string>
<string name="settings_desc_chat_style">How agent replies are rendered</string>
<string name="chat_style_bubbles">Bubbles</string>
<string name="chat_style_full_bleed">Full-bleed</string>
<string name="fullbleed_role_agent">Assistant</string>
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
@@ -999,6 +1004,9 @@
<string name="analytics_output_tokens">Output</string>
<string name="analytics_cache_read">Cache read</string>
<string name="analytics_reasoning">Reasoning</string>
<string name="reasoning_copied">Copied</string>
<string name="reasoning_show_full">Show full</string>
<string name="reasoning_show_less">Show less</string>
<string name="analytics_daily_cost">Daily estimated cost</string>
<string name="analytics_loading_usage">Loading usage analytics…</string>
<string name="analytics_no_cost_data">No cost recorded in this period.</string>
@@ -45,6 +45,35 @@ class ThemePaletteTest {
}
}
/**
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
* on the screen background (HermesScaffold containerColor = background),
* so the full-bleed text pairs must hold against [ColorScheme.background]:
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
* (The header deliberately avoids `primary`: Nord's light mode reuses its
* pastel Frost accent as primary, which cannot reach 3:1 on a light
* background.)
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
* NORD, which the [themes] list above predates) is covered.
*/
@Test
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
ThemePreset.entries.forEach { preset ->
listOf(true, false).forEach { dark ->
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
assertTrue(
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
contrast(scheme.onSurface, scheme.background) >= 4.5f,
)
assertTrue(
"$preset dark=$dark onSurfaceVariant/background contrast must be >= 3:1 (full-bleed header)",
contrast(scheme.onSurfaceVariant, scheme.background) >= 3f,
)
}
}
}
@Test
fun amoledShipsDarkOnly() {
assertTrue("AMOLED must ship a dark scheme", AmoledTheme.darkScheme != null)
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
@@ -89,6 +90,7 @@ class ChatUpdateCommandTest {
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.*
@@ -128,6 +129,7 @@ class ChatViewModelTest {
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
@@ -2716,6 +2718,7 @@ class ChatViewModelTest {
// re-reads AuthManager live (the real regression scenario).
every { AuthManager.isTypingEffectEnabled() } returns false
every { AuthManager.getTypingEffectDelayMs() } returns 50
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
viewModel.refreshSettings()
advanceUntilIdle()
@@ -0,0 +1,148 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.AgentEntry
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurnsWithStreaming
import org.junit.Assert.assertEquals
import org.junit.Test
class FullBleedTurnsTest {
private fun msg(
id: String,
role: MessageRole,
content: String = "c-$id",
toolStatus: ToolStatus? = null,
isStreaming: Boolean = false,
) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming)
private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList())
@Test
fun `empty list produces no turns`() {
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
}
@Test
fun `single user message is its own turn`() {
val m = msg("u1", MessageRole.USER)
assertEquals(listOf<ChatTurn>(ChatTurn.User(m)), groupIntoTurns(listOf(m)))
}
@Test
fun `single assistant message is an agent turn with prose`() {
val m = msg("a1", MessageRole.ASSISTANT)
assertEquals(listOf<ChatTurn>(entries(AgentEntry.Prose(m))), groupIntoTurns(listOf(m)))
}
@Test
fun `user then assistant-tool-assistant groups into user turn plus agent turn`() {
val u = msg("u1", MessageRole.USER)
val a1 = msg("a1", MessageRole.ASSISTANT)
val t = msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED)
val a2 = msg("a2", MessageRole.ASSISTANT)
val result = groupIntoTurns(listOf(u, a1, t, a2))
assertEquals(
listOf(
ChatTurn.User(u),
entries(AgentEntry.Prose(a1), AgentEntry.ToolRow(t), AgentEntry.Prose(a2)),
),
result,
)
}
@Test
fun `two consecutive user messages produce two user turns`() {
val u1 = msg("u1", MessageRole.USER)
val u2 = msg("u2", MessageRole.USER)
assertEquals(listOf(ChatTurn.User(u1), ChatTurn.User(u2)), groupIntoTurns(listOf(u1, u2)))
}
@Test
fun `leading tool and system messages before any assistant become an agent turn`() {
val t = msg("t0", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED)
val s = msg("s0", MessageRole.SYSTEM)
val a = msg("a0", MessageRole.ASSISTANT)
val result = groupIntoTurns(listOf(t, s, a))
assertEquals(
listOf(entries(AgentEntry.ToolRow(t), AgentEntry.SystemEvent(s), AgentEntry.Prose(a))),
result,
)
}
@Test
fun `system event is preserved as a SystemEvent entry not prose`() {
val s = msg("s1", MessageRole.SYSTEM, content = "Self-improvement review: patched skill")
val result = groupIntoTurns(listOf(s))
assertEquals(listOf(entries(AgentEntry.SystemEvent(s))), result)
}
@Test
fun `user message closes the current agent turn`() {
val a1 = msg("a1", MessageRole.ASSISTANT)
val u = msg("u1", MessageRole.USER)
val a2 = msg("a2", MessageRole.ASSISTANT)
val result = groupIntoTurns(listOf(a1, u, a2))
assertEquals(
listOf(
entries(AgentEntry.Prose(a1)),
ChatTurn.User(u),
entries(AgentEntry.Prose(a2)),
),
result,
)
}
// ── groupIntoTurnsWithStreaming ────────────────────────────────────────
@Test
fun `null streaming message falls back to plain grouping`() {
val u = msg("u1", MessageRole.USER)
val a = msg("a1", MessageRole.ASSISTANT)
assertEquals(
groupIntoTurns(listOf(u, a)),
groupIntoTurnsWithStreaming(listOf(u, a), null),
)
}
@Test
fun `streaming prose appends to the current agent turn`() {
val u = msg("u1", MessageRole.USER)
val t = msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED)
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
val result = groupIntoTurnsWithStreaming(listOf(u, t), s)
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.ToolRow(t), AgentEntry.Prose(s))),
result,
)
}
@Test
fun `streaming prose after a user turn opens a new agent turn`() {
val u = msg("u1", MessageRole.USER)
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
val result = groupIntoTurnsWithStreaming(listOf(u), s)
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.Prose(s))),
result,
)
}
@Test
fun `streaming message already present in messages is not duplicated`() {
val u = msg("u1", MessageRole.USER)
val s = msg("s1", MessageRole.ASSISTANT, content = "done", isStreaming = false)
// Commit race: the same id exists in both messages and streamingMessage.
val result = groupIntoTurnsWithStreaming(listOf(u, s), s.copy(isStreaming = true))
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.Prose(s))),
result,
)
}
@Test
fun `empty messages with streaming produce a single agent turn`() {
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
assertEquals(listOf(entries(AgentEntry.Prose(s))), groupIntoTurnsWithStreaming(emptyList(), s))
}
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.every
@@ -93,6 +94,7 @@ class SlashCommandDispatchRpcTest {
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import io.mockk.every
@@ -65,6 +66,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
@@ -119,6 +121,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
every { AuthManager.isTypingEffectEnabled() } returns false
every { AuthManager.getTypingEffectDelayMs() } returns 15
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.getConnectionProfiles() } returns testProfiles
every { AuthManager.getAppLanguage() } returns "fr"