feat(chat): remove legacy bubble renderer, full-bleed is the only chat style (#881)

Deletes the ChatMessageList/ChatStreaming bubble path and the ChatStyle
setting (issue #866 phase 6). ChatScreen now renders FullBleedChatList
directly; ChatBubble becomes the user-message anchor with the shared
SystemBubble/InlineAttachment/formatTimestamp helpers intact. Stubs of
AuthManager.getChatStyle() removed from four test suites; TodoTaskCardTest
renders ToolBubble directly.

Verified: ktlintCheck green, testDebugUnitTest 962/962 pass, assembleDebug
APK builds.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-12 11:02:33 +03:00
1 parent a59c8c6a4f
commit 345d2e4e89
20 files changed
+154 -737

No files matched your search

@@ -1,7 +1,6 @@
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
@@ -22,9 +21,6 @@ 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,7 +17,6 @@ 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
@@ -75,9 +74,6 @@ 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()
@@ -184,7 +180,6 @@ 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) }
}
@@ -589,15 +584,6 @@ 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) }
}
@@ -1,11 +0,0 @@
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 }
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.chat
import android.content.ClipData
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
@@ -74,20 +73,22 @@ import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* The user-message bubble — the universal anchor in the full-bleed chat
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
* this bubble so the conversation stays scannable.
*/
@Composable
fun ChatBubble(
message: ChatMessage,
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
onRespondApproval: (String) -> Unit = {},
onOpenAttachment: (Attachment) -> Unit = {},
onSaveAttachment: (Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
@@ -106,324 +107,133 @@ fun ChatBubble(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
),
) {
when (message.role) {
MessageRole.USER -> {
UserBubble(
message = message,
maxWidth = maxBubbleWidth,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onOpenAttachment = onOpenAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
canSaveAttachment = canSaveAttachment,
onImageClick = onImageClick,
modifier = modifier,
)
}
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
MessageRole.ASSISTANT -> {
AssistantBubble(
message = message,
maxWidth = maxBubbleWidth,
isDarkTheme = isDarkTheme,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onOpenAttachment = onOpenAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
canSaveAttachment = canSaveAttachment,
onImageClick = onImageClick,
modifier = modifier,
)
}
MessageRole.SYSTEM -> {
SystemBubble(
message = message,
onRespondApproval = onRespondApproval,
modifier = modifier,
)
}
MessageRole.TOOL -> {
ToolBubble(message, modifier)
// Copy feedback: briefly show ✓ then revert
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
}
}
@Composable
private fun UserBubble(
message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
onOpenAttachment: (Attachment) -> Unit = {},
onSaveAttachment: (Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
canSaveAttachment: Boolean = true,
onImageClick: (ImageViewerModel) -> Unit = {},
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
// Copy feedback: briefly show ✓ then revert
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
val statusColors = LocalHermesStatusColors.current
val highlightedText =
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
if (searchQuery.isNotBlank()) {
buildHighlightedString(
message.content,
searchQuery,
isCurrentMatch,
statusColors,
)
} else {
AnnotatedString(message.content)
}
}
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterEnd,
) {
val primary = MaterialTheme.colorScheme.primary
val userBubbleTextColor =
if (primary.luminance() > 0.5f) {
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary
val highlightedText =
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
if (searchQuery.isNotBlank()) {
buildHighlightedString(
message.content,
searchQuery,
isCurrentMatch,
statusColors,
)
} else {
LightOnSurface
}
} else {
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
DarkOnSurface
AnnotatedString(message.content)
}
}
Box {
Surface(
modifier =
Modifier
.widthIn(max = maxWidth)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 4.dp,
),
).background(color = primary)
.testTag("chat_bubble_user"),
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = userBubbleTextColor,
style = MaterialTheme.typography.bodyMedium,
)
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterEnd,
) {
val primary = MaterialTheme.colorScheme.primary
val userBubbleTextColor =
if (primary.luminance() > 0.5f) {
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
LightOnSurface
}
// Render inline attachments
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = userBubbleTextColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
} else {
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
DarkOnSurface
}
}
Box {
Surface(
modifier =
Modifier
.widthIn(max = maxBubbleWidth)
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 4.dp,
),
).background(color = primary)
.testTag("chat_bubble_user"),
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = userBubbleTextColor,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
}
copied = true
},
modifier = Modifier.size(20.dp),
// Render inline attachments
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = userBubbleTextColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = userBubbleTextColor.copy(alpha = 0.7f),
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.content)),
)
}
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = userBubbleTextColor.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
}
}
@Composable
private fun AssistantBubble(
message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp,
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
onOpenAttachment: (Attachment) -> Unit = {},
onSaveAttachment: (Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
canSaveAttachment: Boolean = true,
onImageClick: (ImageViewerModel) -> Unit = {},
modifier: Modifier = Modifier,
) {
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
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
}
}
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
Box {
Surface(
modifier =
Modifier
.widthIn(max = maxWidth)
.animateContentSize()
.clip(
RoundedCornerShape(
topStart = 4.dp,
topEnd = 16.dp,
bottomStart = 16.dp,
bottomEnd = 16.dp,
),
).testTag("chat_bubble_assistant"),
color = bubbleColor,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f),
),
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
if (message.reasoningText.isNotBlank()) {
ReasoningCard(
reasoningText = message.reasoningText,
isStreaming = message.isStreaming,
)
Spacer(modifier = Modifier.height(6.dp))
}
SelectionContainer {
MarkdownText(
text = message.content,
textColor = textColor,
isStreaming = message.isStreaming,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
}
// Render inline attachments (mirrors UserBubble so agent-delivered
// media — images, files — shows in assistant bubbles 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) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
}
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = textColor.copy(alpha = 0.6f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = textColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -81,13 +81,11 @@ 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
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
import com.m57.hermescontrol.ui.chat.components.ChatMessageList
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
@@ -580,59 +578,32 @@ fun ChatScreen(
}
}
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 },
)
}
// Full-bleed chat renderer (issue #866) — the single chat
// surface since the bubble renderer was removed.
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,7 +33,6 @@ 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
@@ -274,9 +273,6 @@ 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
@@ -1723,7 +1719,6 @@ class ChatViewModel(
state.copy(
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
chatStyle = AuthManager.getChatStyle(),
)
}
}
@@ -1,169 +0,0 @@
package com.m57.hermescontrol.ui.chat.components
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.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
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.MessageRole
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.toolCallMilestones
import com.m57.hermescontrol.ui.common.EmptyState
/**
* The chat message list.
*/
@Composable
fun ChatMessageList(
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)
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))
}
}
}
itemsIndexed(
items = messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
searchMatchIndices[currentSearchMatchIndex] == index
val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
Column {
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
onAnimationComplete = {
onLastAnimatedMessageIdChange(message.id)
},
)
} else {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch = isCurrentMatch,
onRespondApproval = viewModel::respondToApproval,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
// Subtle beat counter after every 5th tool call (issue #767).
toolMilestones[index]?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
}
}
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
}
}
// 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() },
)
}
}
}
}
}
@@ -1,87 +0,0 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.size
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.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage
import kotlinx.coroutines.delay
/**
* Wraps a streaming [ChatBubble] with a word-by-word typing reveal effect.
* 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.
*/
@Composable
fun StreamingBubbleWithTypingEffect(
streaming: ChatMessage,
typingDelayMs: Int,
isDark: Boolean,
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) // was 10 — reduced from 100Hz to 10Hz
}
}
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 ""
ChatBubble(
message = streaming.copy(content = displayText),
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
@@ -28,12 +28,11 @@ 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:
* The chat message list for FULL-BLEED style (issue #866) — the single chat
* surface since the bubble renderer was removed. 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
*
@@ -112,7 +111,6 @@ fun FullBleedChatList(
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
isDark = isDark,
searchQuery = searchQuery,
isCurrentMatch =
isCurrentMatchFor(
@@ -259,7 +257,6 @@ fun FullBleedChatList(
@Composable
private fun renderChatBubble(
message: ChatMessage,
isDark: Boolean,
searchQuery: String,
isCurrentMatch: Boolean,
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
@@ -269,7 +266,6 @@ private fun renderChatBubble(
) {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onOpenAttachment = onOpenAttachment,
@@ -13,8 +13,7 @@ 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]).
* effect (full-bleed counterpart to the removed bubble streaming wrapper).
*
* Shows words one at a time at [typingDelayMs] intervals while the message is
* still streaming. When streaming completes the full text is shown
@@ -163,8 +163,6 @@ internal fun SettingsChatPage(
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
chatStyle = state.chatStyle,
onChatStyleChange = viewModel::onChatStyleChange,
)
}
}
@@ -11,7 +11,6 @@ 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
@@ -36,7 +35,6 @@ 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 = "",
@@ -77,7 +75,6 @@ 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 =
@@ -97,7 +94,6 @@ class SettingsViewModel(
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs,
chatStyle = chatStyle,
profiles = profiles,
selectedProfileId = selectedId,
renameProfileName = renameProfileName,
@@ -298,11 +294,6 @@ 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)
@@ -329,7 +320,6 @@ class SettingsViewModel(
AuthManager.setThemePreset(state.themePreset)
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
AuthManager.setChatStyle(state.chatStyle)
ApiClient.rebuild()
viewModelScope.launch(ioDispatcher) {
@@ -32,7 +32,6 @@ 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
@@ -235,46 +234,8 @@ 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(