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:
1 parent
a59c8c6a4f
commit
345d2e4e89
20 files changed
+154
-737
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user