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
@@ -42,7 +42,7 @@ class TodoTaskCardTest {
|
|||||||
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
HermesControlTheme {
|
HermesControlTheme {
|
||||||
ChatBubble(message = message, isDarkTheme = true)
|
ToolBubble(message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class TodoTaskCardTest {
|
|||||||
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
HermesControlTheme {
|
HermesControlTheme {
|
||||||
ChatBubble(message = message, isDarkTheme = true)
|
ToolBubble(message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class TodoTaskCardTest {
|
|||||||
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
HermesControlTheme {
|
HermesControlTheme {
|
||||||
ChatBubble(message = message, isDarkTheme = true)
|
ToolBubble(message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class TodoTaskCardTest {
|
|||||||
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
HermesControlTheme {
|
HermesControlTheme {
|
||||||
ChatBubble(message = message, isDarkTheme = true)
|
ToolBubble(message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.config
|
package com.m57.hermescontrol.data.config
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -22,9 +21,6 @@ data class ServerStoreState(
|
|||||||
val wsAuthParam: String = "token",
|
val wsAuthParam: String = "token",
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
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
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
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.CookieManager
|
||||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -75,9 +74,6 @@ object AuthManager {
|
|||||||
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
||||||
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
|
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)
|
private val _tokenFlow = MutableStateFlow<String?>(null)
|
||||||
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
||||||
|
|
||||||
@@ -184,7 +180,6 @@ object AuthManager {
|
|||||||
_themePreferenceFlow.value = state.themePreference
|
_themePreferenceFlow.value = state.themePreference
|
||||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||||
_themePresetFlow.value = state.themePreset
|
_themePresetFlow.value = state.themePreset
|
||||||
_chatStyleFlow.value = state.chatStyle
|
|
||||||
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
||||||
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
||||||
}
|
}
|
||||||
@@ -589,15 +584,6 @@ object AuthManager {
|
|||||||
|
|
||||||
fun getThemePreset(): ThemePreset = serverStore.getLatestState().themePreset
|
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) {
|
fun setThemePreset(preset: ThemePreset) {
|
||||||
serverStore.update { it.copy(themePreset = preset) }
|
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.content.ClipData
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.expandVertically
|
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.LightOnSurface
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.onColorFor
|
import com.m57.hermescontrol.theme.onColorFor
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
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
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
isDarkTheme: Boolean,
|
|
||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
onRespondApproval: (String) -> Unit = {},
|
|
||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
onSaveAttachment: (Attachment) -> Unit = {},
|
onSaveAttachment: (Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
@@ -106,324 +107,133 @@ fun ChatBubble(
|
|||||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
when (message.role) {
|
val clipboard = LocalClipboard.current
|
||||||
MessageRole.USER -> {
|
val scope = rememberCoroutineScope()
|
||||||
UserBubble(
|
var copied by remember { mutableStateOf(false) }
|
||||||
message = message,
|
|
||||||
maxWidth = maxBubbleWidth,
|
|
||||||
searchQuery = searchQuery,
|
|
||||||
isCurrentMatch = isCurrentMatch,
|
|
||||||
onOpenAttachment = onOpenAttachment,
|
|
||||||
onSaveAttachment = onSaveAttachment,
|
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
|
||||||
canSaveAttachment = canSaveAttachment,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageRole.ASSISTANT -> {
|
// Copy feedback: briefly show ✓ then revert
|
||||||
AssistantBubble(
|
LaunchedEffect(copied) {
|
||||||
message = message,
|
if (copied) {
|
||||||
maxWidth = maxBubbleWidth,
|
delay(1500)
|
||||||
isDarkTheme = isDarkTheme,
|
copied = false
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
val statusColors = LocalHermesStatusColors.current
|
||||||
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) }
|
|
||||||
|
|
||||||
// Copy feedback: briefly show ✓ then revert
|
val highlightedText =
|
||||||
LaunchedEffect(copied) {
|
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
||||||
if (copied) {
|
if (searchQuery.isNotBlank()) {
|
||||||
delay(1500)
|
buildHighlightedString(
|
||||||
copied = false
|
message.content,
|
||||||
}
|
searchQuery,
|
||||||
}
|
isCurrentMatch,
|
||||||
|
statusColors,
|
||||||
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
|
|
||||||
} else {
|
} else {
|
||||||
LightOnSurface
|
AnnotatedString(message.content)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary
|
|
||||||
} else {
|
|
||||||
DarkOnSurface
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box {
|
Box(
|
||||||
Surface(
|
modifier =
|
||||||
modifier =
|
modifier
|
||||||
Modifier
|
.fillMaxWidth()
|
||||||
.widthIn(max = maxWidth)
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
.clip(
|
contentAlignment = Alignment.CenterEnd,
|
||||||
RoundedCornerShape(
|
) {
|
||||||
topStart = 16.dp,
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
topEnd = 16.dp,
|
val userBubbleTextColor =
|
||||||
bottomStart = 16.dp,
|
if (primary.luminance() > 0.5f) {
|
||||||
bottomEnd = 4.dp,
|
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
|
||||||
),
|
MaterialTheme.colorScheme.onPrimary
|
||||||
).background(color = primary)
|
} else {
|
||||||
.testTag("chat_bubble_user"),
|
LightOnSurface
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Render inline attachments
|
} else {
|
||||||
if (!message.attachments.isNullOrEmpty()) {
|
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
MaterialTheme.colorScheme.onPrimary
|
||||||
message.attachments.forEach { attachment ->
|
} else {
|
||||||
InlineAttachment(
|
DarkOnSurface
|
||||||
attachment = attachment,
|
}
|
||||||
textColor = userBubbleTextColor,
|
}
|
||||||
onOpen = { onOpenAttachment(it) },
|
Box {
|
||||||
onSave = { onSaveAttachment(it) },
|
Surface(
|
||||||
savingPath = savingAttachmentPath,
|
modifier =
|
||||||
canSave = canSaveAttachment,
|
Modifier
|
||||||
onImageClick = onImageClick,
|
.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))
|
|
||||||
}
|
}
|
||||||
}
|
// Render inline attachments
|
||||||
if (!message.isStreaming) {
|
if (!message.attachments.isNullOrEmpty()) {
|
||||||
Row(
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
modifier =
|
message.attachments.forEach { attachment ->
|
||||||
Modifier
|
InlineAttachment(
|
||||||
.align(Alignment.End)
|
attachment = attachment,
|
||||||
.padding(top = 4.dp),
|
textColor = userBubbleTextColor,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onOpen = { onOpenAttachment(it) },
|
||||||
) {
|
onSave = { onSaveAttachment(it) },
|
||||||
IconButton(
|
savingPath = savingAttachmentPath,
|
||||||
onClick = {
|
canSave = canSaveAttachment,
|
||||||
scope.launch {
|
onImageClick = onImageClick,
|
||||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
)
|
||||||
}
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
copied = true
|
}
|
||||||
},
|
}
|
||||||
modifier = Modifier.size(20.dp),
|
if (!message.isStreaming) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(
|
||||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
onClick = {
|
||||||
contentDescription = stringResource(R.string.content_desc_copy),
|
scope.launch {
|
||||||
modifier = Modifier.size(12.dp),
|
clipboard.setClipEntry(
|
||||||
tint = userBubbleTextColor.copy(alpha = 0.7f),
|
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.model.AttachmentSource
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
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.ChatResumeErrorOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
||||||
@@ -580,59 +578,32 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.chatStyle) {
|
// Full-bleed chat renderer (issue #866) — the single chat
|
||||||
ChatStyle.BUBBLES ->
|
// surface since the bubble renderer was removed.
|
||||||
ChatMessageList(
|
FullBleedChatList(
|
||||||
messages = state.messages,
|
messages = state.messages,
|
||||||
streamingMessage = streamingState.streamingMessage,
|
streamingMessage = streamingState.streamingMessage,
|
||||||
isSearchActive = state.isSearchActive,
|
isSearchActive = state.isSearchActive,
|
||||||
searchQuery = state.searchQuery,
|
searchQuery = state.searchQuery,
|
||||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
||||||
searchMatchIndices = state.searchMatchIndices,
|
searchMatchIndices = state.searchMatchIndices,
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
isLoadingOlder = state.isLoadingOlder,
|
isLoadingOlder = state.isLoadingOlder,
|
||||||
isDark = isDark,
|
isDark = isDark,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest,
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
onImageClick = { viewingImage = it },
|
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
|
// Loading overlay
|
||||||
ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null)
|
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.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -274,9 +273,6 @@ data class ChatUiState(
|
|||||||
// Cached settings
|
// Cached settings
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
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
|
// Commands catalog
|
||||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||||
@@ -1723,7 +1719,6 @@ class ChatViewModel(
|
|||||||
state.copy(
|
state.copy(
|
||||||
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||||
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
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
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat message list for FULL-BLEED style (issue #866).
|
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||||
*
|
* surface since the bubble renderer was removed. User messages keep their
|
||||||
* Parallel renderer to [com.m57.hermescontrol.ui.chat.components.ChatMessageList]:
|
* bubble (the universal anchor), agent turns render full-bleed with a turn
|
||||||
* user messages keep their bubble (the universal anchor), agent turns render
|
* header, and tool rows / system events render as distinct compact cards.
|
||||||
* full-bleed with a turn header, and tool rows / system events render as
|
* Spacing contract:
|
||||||
* distinct compact cards. Spacing contract:
|
|
||||||
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
|
* - 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
|
* - 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)) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
renderChatBubble(
|
renderChatBubble(
|
||||||
message = userMessage,
|
message = userMessage,
|
||||||
isDark = isDark,
|
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch =
|
isCurrentMatch =
|
||||||
isCurrentMatchFor(
|
isCurrentMatchFor(
|
||||||
@@ -259,7 +257,6 @@ fun FullBleedChatList(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun renderChatBubble(
|
private fun renderChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
isDark: Boolean,
|
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
isCurrentMatch: Boolean,
|
isCurrentMatch: Boolean,
|
||||||
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
||||||
@@ -269,7 +266,6 @@ private fun renderChatBubble(
|
|||||||
) {
|
) {
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isDarkTheme = isDark,
|
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
onOpenAttachment = onOpenAttachment,
|
onOpenAttachment = onOpenAttachment,
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import kotlinx.coroutines.delay
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a streaming [FullBleedAgentMessage] with a word-by-word typing reveal
|
* Wraps a streaming [FullBleedAgentMessage] with a word-by-word typing reveal
|
||||||
* effect (full-bleed counterpart to
|
* effect (full-bleed counterpart to the removed bubble streaming wrapper).
|
||||||
* [com.m57.hermescontrol.ui.chat.components.StreamingBubbleWithTypingEffect]).
|
|
||||||
*
|
*
|
||||||
* Shows words one at a time at [typingDelayMs] intervals while the message is
|
* Shows words one at a time at [typingDelayMs] intervals while the message is
|
||||||
* still streaming. When streaming completes the full text is shown
|
* still streaming. When streaming completes the full text is shown
|
||||||
|
|||||||
@@ -163,8 +163,6 @@ internal fun SettingsChatPage(
|
|||||||
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
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.ServerEndpoint
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -36,7 +35,6 @@ data class SettingsUiState(
|
|||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
val typingEffectEnabled: Boolean = false,
|
val typingEffectEnabled: Boolean = false,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
|
|
||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
@@ -77,7 +75,6 @@ class SettingsViewModel(
|
|||||||
val themePreset = AuthManager.getThemePreset()
|
val themePreset = AuthManager.getThemePreset()
|
||||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
val chatStyle = AuthManager.getChatStyle()
|
|
||||||
val profiles = AuthManager.getConnectionProfiles()
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
val appLanguage = AuthManager.getAppLanguage()
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
@@ -97,7 +94,6 @@ class SettingsViewModel(
|
|||||||
themePreset = themePreset,
|
themePreset = themePreset,
|
||||||
typingEffectEnabled = typingEffectEnabled,
|
typingEffectEnabled = typingEffectEnabled,
|
||||||
typingEffectDelayMs = typingEffectDelayMs,
|
typingEffectDelayMs = typingEffectDelayMs,
|
||||||
chatStyle = chatStyle,
|
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
@@ -298,11 +294,6 @@ class SettingsViewModel(
|
|||||||
AuthManager.setTypingEffectDelayMs(delayMs)
|
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. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
@@ -329,7 +320,6 @@ class SettingsViewModel(
|
|||||||
AuthManager.setThemePreset(state.themePreset)
|
AuthManager.setThemePreset(state.themePreset)
|
||||||
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
||||||
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||||
AuthManager.setChatStyle(state.chatStyle)
|
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
@@ -235,46 +234,8 @@ internal fun ChatSection(
|
|||||||
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
onTypingEffectDelayMsChange: (Int) -> Unit,
|
||||||
chatStyle: ChatStyle,
|
|
||||||
onChatStyleChange: (ChatStyle) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
SectionCard {
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -143,10 +143,6 @@
|
|||||||
<string name="settings_sec_chat">채팅</string>
|
<string name="settings_sec_chat">채팅</string>
|
||||||
<string name="settings_item_typing_effect">타이핑 효과</string>
|
<string name="settings_item_typing_effect">타이핑 효과</string>
|
||||||
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
|
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
|
||||||
<string name="settings_item_chat_style">채팅 스타일</string>
|
|
||||||
<string name="settings_desc_chat_style">에이전트 응답 표시 방식</string>
|
|
||||||
<string name="chat_style_bubbles">버블</string>
|
|
||||||
<string name="chat_style_full_bleed">전체 폭</string>
|
|
||||||
<string name="fullbleed_role_agent">어시스턴트</string>
|
<string name="fullbleed_role_agent">어시스턴트</string>
|
||||||
<string name="settings_item_typing_delay">지연: %1$d ms</string>
|
<string name="settings_item_typing_delay">지연: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
|
|||||||
@@ -163,10 +163,6 @@
|
|||||||
<string name="settings_sec_chat">Chat</string>
|
<string name="settings_sec_chat">Chat</string>
|
||||||
<string name="settings_item_typing_effect">Typing Effect</string>
|
<string name="settings_item_typing_effect">Typing Effect</string>
|
||||||
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
|
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
|
||||||
<string name="settings_item_chat_style">Chat style</string>
|
|
||||||
<string name="settings_desc_chat_style">How agent replies are rendered</string>
|
|
||||||
<string name="chat_style_bubbles">Bubbles</string>
|
|
||||||
<string name="chat_style_full_bleed">Full-bleed</string>
|
|
||||||
<string name="fullbleed_role_agent">Assistant</string>
|
<string name="fullbleed_role_agent">Assistant</string>
|
||||||
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
|
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
|
|||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
@@ -90,7 +89,6 @@ class ChatUpdateCommandTest {
|
|||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
|
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
every { HermesWsClient.events } returns mockEventsFlow
|
every { HermesWsClient.events } returns mockEventsFlow
|
||||||
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
|||||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
@@ -129,7 +128,6 @@ class ChatViewModelTest {
|
|||||||
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
|
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
every { HermesWsClient.events } returns mockEventsFlow
|
every { HermesWsClient.events } returns mockEventsFlow
|
||||||
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
||||||
@@ -2718,7 +2716,6 @@ class ChatViewModelTest {
|
|||||||
// re-reads AuthManager live (the real regression scenario).
|
// re-reads AuthManager live (the real regression scenario).
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns false
|
every { AuthManager.isTypingEffectEnabled() } returns false
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 50
|
every { AuthManager.getTypingEffectDelayMs() } returns 50
|
||||||
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
|
|
||||||
viewModel.refreshSettings()
|
viewModel.refreshSettings()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
|
|||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.WsMethods
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -94,7 +93,6 @@ class SlashCommandDispatchRpcTest {
|
|||||||
every { AuthManager.getToken() } returns "test-token"
|
every { AuthManager.getToken() } returns "test-token"
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
|
|
||||||
every { AuthManager.isAutoReconnect() } returns false
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
every { HermesWsClient.events } returns mockEventsFlow
|
every { HermesWsClient.events } returns mockEventsFlow
|
||||||
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.settings
|
|||||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.theme.ChatStyle
|
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -66,7 +65,6 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
|
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
|
|
||||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||||
@@ -121,7 +119,6 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
|
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns false
|
every { AuthManager.isTypingEffectEnabled() } returns false
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 15
|
every { AuthManager.getTypingEffectDelayMs() } returns 15
|
||||||
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
|
|
||||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||||
every { AuthManager.getAppLanguage() } returns "fr"
|
every { AuthManager.getAppLanguage() } returns "fr"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user