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

@@ -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,