feat(chat): add explicit busy-send modes and recoverable queue (#1254)

Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andm57 authored and GitHub committed 2026-09-25 13:26:25 +03:00
1 parent f3921957b5
commit 35e6ebc8d5
33 files changed
+4482 -229

No files matched your search

@@ -4,16 +4,19 @@ import android.content.pm.PackageManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -125,4 +128,37 @@ class ChatScreenTest {
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
@Test
fun chatScreen_primarySendUsesSavedBusyDefault() {
val uiState =
ChatUiState(
connectionStatus = ConnectionStatus.CONNECTED,
isSessionReady = true,
busySendMode = BusySendMode.QUEUE,
)
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
every { mockViewModel.sendMessage(any(), any()) } returns true
composeTestRule.setContent {
ChatScreen(
onOpenDrawer = {},
sessionId = null,
viewModel = mockViewModel,
)
}
composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default")
composeTestRule.onNodeWithTag("send_button").performClick()
verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) }
verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) }
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -24,9 +27,12 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -42,7 +48,7 @@ import org.junit.runner.RunWith
@MediumTest
class ComposerInteractionTest {
@get:Rule
val composeTestRule = createComposeRule()
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private var micTaps = 0
private var sends = 0
@@ -52,6 +58,158 @@ class ComposerInteractionTest {
private var photosTaps = 0
private var fileTaps = 0
private fun setContextualComposer(
text: String,
isMainTurnBusy: Boolean,
attachments: List<Attachment> = emptyList(),
onSend: () -> Unit = {},
onBusySend: (BusySendMode) -> Unit = {},
isConnected: Boolean = true,
pendingReasoningLevel: String? = null,
) {
composeTestRule.setContent {
ChatInputBar(
inputFieldValue = TextFieldValue(text),
onInputChange = {},
onSend = onSend,
onBusySend = onBusySend,
onMicTap = {},
isListening = false,
isAgentTyping = isMainTurnBusy,
isMainTurnBusy = isMainTurnBusy,
isConnected = isConnected,
commandCatalog = CommandCatalog(),
isSessionReady = true,
pendingAttachments = attachments,
pendingReasoningLevel = pendingReasoningLevel,
)
}
}
@Test
fun idleDraft_showsRegularSendWithoutBusyActions() {
setContextualComposer(text = "hello", isMainTurnBusy = false)
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
}
@Test
fun busyBlankDraft_hidesBusyActions() {
setContextualComposer(text = "", isMainTurnBusy = true)
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
}
@Test
fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() {
var regularSends = 0
val selected = mutableListOf<BusySendMode>()
setContextualComposer(
text = "change course",
isMainTurnBusy = true,
onSend = { regularSends++ },
onBusySend = selected::add,
)
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick()
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick()
composeTestRule.runOnIdle {
assertEquals(1, regularSends)
assertEquals(listOf(BusySendMode.QUEUE), selected)
}
}
@Test
fun busyAttachmentOnly_showsSendAndQueue() {
val attachment = Attachment("content://document/1", "notes.txt", "text/plain")
setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment))
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
}
@Test
fun busyDisconnectedDraft_disablesSendAndQueue() {
setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false)
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
}
@Test
fun busyPendingReasoning_disablesSendAndQueue() {
setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high")
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
}
@Test
fun busySlashDraft_usesRegularSendWithoutBusyActions() {
var regularSends = 0
setContextualComposer(
text = "/stop",
isMainTurnBusy = true,
onSend = { regularSends++ },
)
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("send_button").performClick()
composeTestRule.runOnIdle { assertEquals(1, regularSends) }
}
@Test
fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() {
val busy = mutableStateOf(true)
composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("draft in progress")) }
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = {},
onBusySend = {},
onMicTap = {},
isListening = false,
isAgentTyping = busy.value,
isMainTurnBusy = busy.value,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
composeTestRule.runOnUiThread { busy.value = false }
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress")
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
@Test
fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() {
composeTestRule.setContent {
Box(Modifier.width(220.dp)) {
ChatInputBar(
inputFieldValue = TextFieldValue("change course"),
onInputChange = {},
onSend = {},
onBusySend = {},
onMicTap = {},
isListening = false,
isAgentTyping = true,
isMainTurnBusy = true,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = true,
)
}
}
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
}
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer(
reasoningLevel: String? = "medium",
@@ -246,7 +404,8 @@ class ComposerInteractionTest {
"short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth"
}
check(actionButton.right > toolbarBounds.right - 32.dp) {
"trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
"trailing action should stay near the toolbar edge, " +
"right=${actionButton.right} toolbarRight=${toolbarBounds.right}"
}
}
@@ -337,11 +496,11 @@ class ComposerInteractionTest {
}
@Test
fun attachmentTray_dismissesOnBackPress() {
fun attachmentTray_dismissesOnActivityBack() {
setComposer()
openAttachmentTray()
pressBack()
composeTestRule.runOnUiThread { composeTestRule.activity.onBackPressedDispatcher.onBackPressed() }
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PendingSendPanelTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private fun row(state: PendingSendState) =
PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state)
@Test
fun ordinarySendNeverShowsDeliveryPanel() {
val state = mutableStateOf(PendingSendState.SENDING)
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist()
}
@Test
fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() {
val state = mutableStateOf(PendingSendState.QUEUED)
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.SENDING }
compose.onNodeWithText("My message").assertDoesNotExist()
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
compose.onNodeWithText("My message").assertDoesNotExist()
}
@Test
fun failedAndUncertainDeliveryRemainRecoverable() {
val state = mutableStateOf(PendingSendState.REJECTED)
var retried: String? = null
compose.setContent {
PendingSendPanel(
listOf(row(state.value)),
mainTurnBusy = false,
onSendNow = { retried = it },
)
}
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick()
compose.runOnIdle {
assertEquals("receipt", retried)
state.value = PendingSendState.UNKNOWN
}
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed()
}
@Test
fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() {
val state = mutableStateOf(PendingSendState.QUEUED)
compose.setContent {
PendingSendPanel(
sends = listOf(row(state.value)),
mainTurnBusy = true,
onSendNow = {},
)
}
listOf(
PendingSendState.QUEUED,
PendingSendState.PARKED,
PendingSendState.REJECTED,
PendingSendState.UNKNOWN,
).forEach { pendingState ->
compose.runOnIdle { state.value = pendingState }
compose
.onNodeWithText(
compose.activity.getString(R.string.chat_pending_send_now_warning),
).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist()
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist()
}
}
}
@@ -1,14 +1,24 @@
package com.m57.hermescontrol.ui.settings.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -20,6 +30,25 @@ class ChatSectionTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun busySendDefaultDropdownRoutesSelection() {
var chosen: BusySendMode? = null
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
busySendMode = BusySendMode.CORRECT,
onBusySendModeChange = { chosen = it },
)
}
composeTestRule.onNodeWithTag("busy_send_default").performClick()
val queueLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_busy_queue)
composeTestRule.onNodeWithText(queueLabel).performClick()
assertEquals(BusySendMode.QUEUE, chosen)
}
@Test
fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() {
composeTestRule.setContent {
@@ -48,31 +77,47 @@ class ChatSectionTest {
var assistantChanges = 0
var tpsChanges = 0
composeTestRule.setContent {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
// PR #1254: match the real settings page's scrollable viewport.
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
ChatSection(
typingEffectEnabled = false,
onTypingEffectEnabledChange = {},
typingEffectDelayMs = 30,
onTypingEffectDelayMsChange = {},
messageStatsEnabled = true,
onMessageStatsEnabledChange = { masterChanges++ },
showUserMessageTokens = true,
onUserMessageTokensChange = { userChanges++ },
showAssistantMessageTokens = true,
onAssistantMessageTokensChange = { assistantChanges++ },
showTokensPerSecond = true,
onTokensPerSecondChange = { tpsChanges++ },
)
}
}
composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick()
composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick()
composeTestRule
.onNodeWithTag("settings_user_message_tokens")
.performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag(
"settings_assistant_message_tokens",
).performScrollTo()
.assertIsEnabled()
.performClick()
composeTestRule
.onNodeWithTag("settings_tps")
.performScrollTo()
.assertIsEnabled()
.performClick()
assertEquals(0, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
assertEquals(1, tpsChanges)
composeTestRule.onNodeWithTag("settings_message_stats").performClick()
composeTestRule.onNodeWithTag("settings_message_stats").performScrollTo().performClick()
assertEquals(1, masterChanges)
assertEquals(1, userChanges)
assertEquals(1, assistantChanges)
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.config
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
@@ -22,6 +23,7 @@ data class ServerStoreState(
val pinnedModels: List<PinnedModel> = emptyList(),
val wsAuthParam: String = "token",
val typingEffectEnabled: Boolean = true,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
@@ -805,6 +806,12 @@ object AuthManager {
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode
fun setBusySendMode(mode: BusySendMode) {
serverStore.update { it.copy(busySendMode = mode) }
}
fun setTypingEffectEnabled(enabled: Boolean) {
serverStore.update { it.copy(typingEffectEnabled = enabled) }
}
@@ -86,4 +86,7 @@ interface ChatMessageDao {
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL")
suspend fun deleteUnconfirmedMessage(id: String)
}
@@ -0,0 +1,11 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
@Serializable
enum class BusySendMode {
CORRECT,
QUEUE,
GUIDE,
INTERRUPT,
}
@@ -11,6 +11,7 @@ object WsMethods {
const val SESSION_CREATE = "session.create"
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_REDIRECT = "session.redirect"
const val SESSION_STEER = "session.steer"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val SESSION_BRANCH = "session.branch"
@@ -0,0 +1,15 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
@Composable
fun BusySendMode.label(): String =
when (this) {
BusySendMode.CORRECT -> stringResource(R.string.chat_busy_correct)
BusySendMode.QUEUE -> stringResource(R.string.chat_busy_queue)
BusySendMode.GUIDE -> stringResource(R.string.chat_busy_guide)
BusySendMode.INTERRUPT -> stringResource(R.string.chat_busy_interrupt)
}
@@ -84,4 +84,8 @@ open class ChatPersistenceRepository(
suspend fun clearMessagesForSession(sessionId: String) {
daoProvider().deleteMessagesForSession(sessionId)
}
suspend fun deleteMessage(id: String) {
daoProvider().deleteUnconfirmedMessage(id)
}
}
@@ -0,0 +1,17 @@
package com.m57.hermescontrol.ui.chat
/** Presentation only: retain queued messages internally for persistence and receipt matching. */
internal fun messagesWithoutUnsentQueue(
messages: List<ChatMessage>,
pendingSends: List<PendingSend>,
): List<ChatMessage> {
val waitingIds =
pendingSends
.filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
.map { it.id }
.toSet()
if (waitingIds.isEmpty()) return messages
return messages.filterNot {
it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
}
}
@@ -86,6 +86,7 @@ import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.reasoningSupport
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -172,7 +173,11 @@ fun ChatScreen(
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val displayedMessages = timelineState.historyMessages ?: state.messages
val sourceMessages = timelineState.historyMessages ?: state.messages
val displayedMessages =
remember(sourceMessages, state.pendingSends) {
messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
}
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
@@ -195,6 +200,7 @@ fun ChatScreen(
}
Lifecycle.Event.ON_RESUME -> {
viewModel.refreshSettings()
connectorsViewModel.onResume()
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
@@ -314,7 +320,7 @@ fun ChatScreen(
// streaming tail.
LaunchedEffect(
timelineState.isHistorical,
state.messages,
displayedMessages,
streamingState.streamingMessage,
streamingState.isThinking,
state.subagentIndicators,
@@ -328,13 +334,13 @@ fun ChatScreen(
scrollController.onTailChanged(
tailKey =
tailContentKey(
messages = state.messages,
messages = displayedMessages,
streamingMessage = streamingState.streamingMessage,
isThinking = streamingState.isThinking,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
),
messageCount = state.messages.size,
messageCount = displayedMessages.size,
)
}
LaunchedEffect(timelineState.historyAnchorRowId) {
@@ -872,6 +878,12 @@ fun ChatScreen(
},
)
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
sends = state.pendingSends,
mainTurnBusy = state.isMainTurnBusy,
onSendNow = viewModel::sendQueuedNow,
)
ChatInputBar(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
@@ -882,9 +894,16 @@ fun ChatScreen(
scrollController.jumpToBottom(animated = true)
}
},
onBusySend = { mode ->
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
inputFieldValue = TextFieldValue("")
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isMainTurnBusy = state.isMainTurnBusy,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
sessionPreparationFailed = state.resumeError != null,
@@ -0,0 +1,149 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** The receipt stays on device until REST confirms a user row; a gateway queue is only volatile. */
@Serializable
data class PendingSend(
val id: String,
val scope: String,
val sessionId: String,
val text: String,
val attachments: List<Attachment> = emptyList(),
val mode: BusySendMode,
val state: PendingSendState = PendingSendState.QUEUED,
val createdAt: Long = System.currentTimeMillis(),
val attempts: Int = 0,
/** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */
val requiresAttachmentRecovery: Boolean = false,
)
@Serializable
enum class PendingSendState {
QUEUED,
PARKED,
SENDING,
ACCEPTED,
UNKNOWN,
REJECTED,
}
/**
* Reconcile only identities that the transcript merge already mapped to a durable REST row.
* Content matching belongs to [matchTranscriptMessages], which normalizes gateway wrappers and
* consumes duplicate occurrences once; repeating that matching against receipts can acknowledge
* more sends than the history page actually contains.
*/
internal fun pendingSendIdsConfirmedByDurableAliases(
confirmedAliases: List<ChatMessage>,
pending: List<PendingSend>,
): Set<String> {
val durableUserAliasIds =
confirmedAliases
.asSequence()
.filter { it.role == MessageRole.USER && it.canonicalRestId != null }
.map { it.id }
.toSet()
return pending
.asSequence()
.filter {
it.state in
setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
}.map { it.id }
.filter { it in durableUserAliasIds }
.toSet()
}
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
class ChatSendStore(
private val prefs: SharedPreferences? = null,
) {
constructor(context: Context) : this(
context.applicationContext.getSharedPreferences("chat_send_outbox", Context.MODE_PRIVATE),
)
private val json = Json { ignoreUnknownKeys = true }
private var rows: List<PendingSend> =
prefs?.getString("rows", null)?.let { raw ->
runCatching { json.decodeFromString<List<PendingSend>>(raw) }.getOrDefault(emptyList())
} ?: emptyList()
init {
// A request may have reached the gateway before process death. Never replay it automatically.
replace(
rows.map {
it.quarantineLegacyAttachments().let { quarantined ->
if (quarantined.state == PendingSendState.SENDING ||
quarantined.state == PendingSendState.ACCEPTED
) {
quarantined.copy(state = PendingSendState.UNKNOWN)
} else {
quarantined
}
}
},
)
}
private fun PendingSend.quarantineLegacyAttachments(): PendingSend =
if (attachments.any { !it.uri.startsWith("file:", ignoreCase = true) }) {
copy(state = PendingSendState.REJECTED, requiresAttachmentRecovery = true)
} else {
this
}
@Synchronized
fun all(): List<PendingSend> = rows
@Synchronized
fun put(row: PendingSend) {
replace(rows.filterNot { it.id == row.id } + row.quarantineLegacyAttachments())
}
@Synchronized
fun remove(id: String) {
replace(rows.filterNot { it.id == id })
}
@Synchronized
fun update(
id: String,
transform: (PendingSend) -> PendingSend,
) {
replace(rows.map { if (it.id == id) transform(it) else it })
}
@Synchronized
fun promote(id: String) {
val row = rows.firstOrNull { it.id == id } ?: return
replace(listOf(row.copy(state = PendingSendState.QUEUED)) + rows.filterNot { it.id == id })
}
@Synchronized
fun park(
scope: String,
sessionId: String,
) {
replace(
rows.map {
if (it.scope == scope && it.sessionId == sessionId && it.state == PendingSendState.QUEUED) {
it.copy(state = PendingSendState.PARKED)
} else {
it
}
},
)
}
private fun replace(next: List<PendingSend>) {
if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) {
error("Could not save chat send queue")
}
rows = next
}
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -37,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +61,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -75,9 +78,11 @@ fun ChatInputBar(
inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onBusySend: (BusySendMode) -> Unit = {},
onMicTap: () -> Unit,
isListening: Boolean,
isAgentTyping: Boolean,
isMainTurnBusy: Boolean = false,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
@@ -111,10 +116,14 @@ fun ChatInputBar(
val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette()
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
AnimatedVisibility(
visible = true,
@@ -319,8 +328,11 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
showSend = hasDraft,
showQueue = showBusyActions,
onSend = onSend,
onQueue = { onBusySend(BusySendMode.QUEUE) },
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
@@ -6,8 +6,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -27,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddToQueue
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Stop
@@ -56,7 +59,9 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -83,6 +88,7 @@ import com.m57.hermescontrol.R
* When [supportsReasoning] is false the model takes no reasoning parameter
* and the level menu is disabled.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ComposerToolbar(
isConnected: Boolean,
@@ -96,7 +102,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
showQueue: Boolean = false,
onSend: () -> Unit = {},
onQueue: () -> Unit = {},
onStopAndSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
@@ -430,29 +439,62 @@ fun ComposerToolbar(
}
}
AnimatedVisibility(
visible = showQueue,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
FilledIconButton(
onClick = onQueue,
enabled = canSend,
colors = flatIconButtonColors(palette),
modifier = Modifier.size(48.dp).testTag("queue_button"),
) {
Icon(
imageVector = Icons.Default.AddToQueue,
contentDescription = stringResource(R.string.chat_busy_queue_action),
modifier = Modifier.size(20.dp),
)
}
}
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (showSend) onSend else onMicTap,
enabled = if (showSend) canSend else isConnected,
colors =
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.action,
contentColor = palette.onAction,
)
},
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
val actionEnabled = if (showSend) canSend else isConnected
Box(
modifier =
Modifier
.size(ControlSize)
.testTag(
.size(if (showSend) 48.dp else ControlSize)
.clip(CircleShape)
.background(
if (!showSend && isListening) {
MaterialTheme.colorScheme.errorContainer
} else {
palette.action
},
).semantics {
if (showSend && showQueue && canSend) {
customActions =
listOf(
CustomAccessibilityAction(stopAndSendLabel) {
onStopAndSend()
true
},
)
}
}.combinedClickable(
enabled = actionEnabled,
onClick = if (showSend) onSend else onMicTap,
onLongClick = if (showSend && showQueue) onStopAndSend else null,
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
).testTag(
when {
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState =
@@ -468,15 +510,24 @@ fun ComposerToolbar(
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
tint = palette.onAction,
)
}
ActionGlyph.STOP -> {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening")
Icon(
imageVector = Icons.Default.Stop,
contentDescription = "Stop listening",
tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
)
}
ActionGlyph.VOICE -> {
Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic")
Icon(
imageVector = Icons.Outlined.Mic,
contentDescription = "Mic",
tint = palette.onAction,
)
}
}
}
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
@Composable
fun PendingSendPanel(
sends: List<PendingSend>,
mainTurnBusy: Boolean,
onSendNow: (String) -> Unit,
) {
// Delivery receipts stay internal; only queued work and recovery need UI.
val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
if (visibleSends.isEmpty()) return
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
Text(
stringResource(R.string.chat_pending_send_now_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
items(visibleSends, key = { it.id }) { send ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Text(
send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
when (send.state) {
PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (send.state in
setOf(
PendingSendState.QUEUED,
PendingSendState.PARKED,
PendingSendState.UNKNOWN,
PendingSendState.REJECTED,
PendingSendState.ACCEPTED,
)
) {
TextButton(onClick = { onSendNow(send.id) }) {
Text(
stringResource(
if (mainTurnBusy) {
R.string.chat_busy_stop_and_send
} else if (send.state in
setOf(
PendingSendState.UNKNOWN,
PendingSendState.ACCEPTED,
)
) {
R.string.chat_pending_send_again
} else {
R.string.chat_pending_send_now
},
),
)
}
}
}
}
}
}
}
}
@@ -193,6 +193,8 @@ internal fun SettingsChatPage(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ChatSection(
busySendMode = state.busySendMode,
onBusySendModeChange = viewModel::onBusySendModeChange,
typingEffectEnabled = state.typingEffectEnabled,
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.resolveBaseUrl
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -37,6 +38,7 @@ data class SettingsUiState(
val testResult: String? = null,
val isSaved: Boolean = false,
val typingEffectEnabled: Boolean = false,
val busySendMode: BusySendMode = BusySendMode.CORRECT,
val typingEffectDelayMs: Int = 30,
val chatFontScale: Float = 1.0f,
val messageStatsEnabled: Boolean = false,
@@ -85,6 +87,7 @@ class SettingsViewModel(
val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val busySendMode = AuthManager.getBusySendMode()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
@@ -112,6 +115,7 @@ class SettingsViewModel(
useDynamicColors = useDynamicColors,
themePreset = themePreset,
typingEffectEnabled = typingEffectEnabled,
busySendMode = busySendMode,
typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
messageStatsEnabled = messageStatsEnabled,
@@ -336,6 +340,11 @@ class SettingsViewModel(
AuthManager.setTypingEffectEnabled(enabled)
}
fun onBusySendModeChange(mode: BusySendMode) {
_uiState.update { it.copy(busySendMode = mode, isSaved = false) }
AuthManager.setBusySendMode(mode)
}
fun onTypingEffectDelayMsChange(delayMs: Int) {
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
AuthManager.setTypingEffectDelayMs(delayMs)
@@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.ui.chat.label
import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -215,6 +217,8 @@ internal fun ChatSection(
onTokensPerSecondChange: (Boolean) -> Unit = {},
showModelProvider: Boolean = false,
onShowModelProviderChange: (Boolean) -> Unit = {},
busySendMode: BusySendMode = BusySendMode.CORRECT,
onBusySendModeChange: (BusySendMode) -> Unit = {},
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
@@ -223,6 +227,38 @@ internal fun ChatSection(
.takeIf { it >= 0 } ?: 1
SectionCard {
var busyModeExpanded by remember { mutableStateOf(false) }
Text(
text = stringResource(R.string.chat_busy_mode_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.chat_busy_mode_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) {
Text(busySendMode.label())
}
DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) {
BusySendMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.label()) },
onClick = {
onBusySendModeChange(mode)
busyModeExpanded = false
},
)
}
}
}
Text(
text = stringResource(R.string.chat_busy_interrupt_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
+23 -1
View File
@@ -1543,6 +1543,7 @@
<string name="media_player_play_video">تشغيل الفيديو</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
<string name="connection_setup_target_title">إعداد %1$s</string>
<string name="connection_setup_progress">%1$d من %2$d</string>
<string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string>
@@ -1556,4 +1557,25 @@
<string name="connection_setup_required">%1$s مطلوب</string>
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string>
</resources>
<string name="chat_busy_mode_title">أثناء عمل الوكيل</string>
<string name="chat_busy_mode_desc">اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.</string>
<string name="chat_busy_correct">تصحيح الدور الحالي</string>
<string name="chat_busy_queue">الإرسال في الدور التالي</string>
<string name="chat_busy_guide">توجيه عند النقطة الآمنة التالية</string>
<string name="chat_busy_interrupt">مقاطعة قسرية ثم إرسال</string>
<string name="chat_busy_interrupt_warning">قد توقف المقاطعة القسرية الوكلاء الفرعيين. يسمح الانتظار والتوجيه باستمرار العمل الحالي.</string>
<string name="chat_busy_override">وضع إرسال هذه الرسالة</string>
<string name="chat_busy_override_once">يسري هذا الاختيار مرة واحدة فقط ولا يغير الإعداد الافتراضي المحفوظ.</string>
<string name="chat_pending_sends">رسائل معلّقة</string>
<string name="chat_pending_queued">في قائمة الانتظار على هذا الجهاز</string>
<string name="chat_pending_parked">موقوفة بعد الإيقاف</string>
<string name="chat_pending_sending">جارٍ الإرسال…</string>
<string name="chat_pending_accepted">قبلها الخادم؛ بانتظار ظهورها في السجل. قد تؤدي إعادة الإرسال إلى تكرارها.</string>
<string name="chat_pending_unknown">حالة التسليم غير معروفة؛ قد يؤدي الإرسال مجددًا إلى تكرارها</string>
<string name="chat_pending_rejected">لم تُسلّم؛ اختر الإرسال الآن لإعادة المحاولة</string>
<string name="chat_pending_send_now">إرسال الآن</string>
<string name="chat_pending_send_now_warning">قد يقطع إرسال رسالة معلّقة الآن العمل الجاري ويوقف الوكلاء الفرعيين.</string>
<string name="chat_pending_send_again">إرسال مجددًا</string>
<string name="chat_pending_attachments">ستُرسل المرفقات مع هذه الرسالة.</string>
</resources>
+23 -1
View File
@@ -1199,6 +1199,7 @@
<string name="media_player_play_video">비디오 재생</string>
<string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
<string name="connection_setup_target_title">%1$s 설정</string>
<string name="connection_setup_progress">%2$d개 중 %1$d개</string>
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
@@ -1239,4 +1240,25 @@
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
<string name="kanban_active_workers">활성 작업자</string>
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
</resources>
<string name="chat_busy_mode_title">에이전트가 작업 중일 때</string>
<string name="chat_busy_mode_desc">새 메시지를 현재 채팅에 전달할 방식을 선택합니다. 유휴 상태에서는 일반 전송됩니다.</string>
<string name="chat_busy_correct">현재 턴 수정</string>
<string name="chat_busy_queue">다음 턴 대기열에 추가</string>
<string name="chat_busy_guide">다음 안전 지점에서 안내</string>
<string name="chat_busy_interrupt">강제 중단 후 전송</string>
<string name="chat_busy_interrupt_warning">강제 중단은 하위 에이전트를 중지할 수 있습니다. 대기열과 안내는 현재 작업을 계속합니다.</string>
<string name="chat_busy_override">이 메시지의 전송 방식</string>
<string name="chat_busy_override_once">이 선택은 한 번만 적용되며 저장된 기본값은 바뀌지 않습니다.</string>
<string name="chat_pending_sends">대기 중인 메시지</string>
<string name="chat_pending_queued">이 기기에서 대기 중</string>
<string name="chat_pending_parked">중지 후 보류됨</string>
<string name="chat_pending_sending">전송 중…</string>
<string name="chat_pending_accepted">서버에서 수락됨; 대화 기록 대기 중. 다시 보내면 중복될 수 있습니다.</string>
<string name="chat_pending_unknown">전달 여부를 알 수 없습니다. 다시 보내면 중복될 수 있습니다.</string>
<string name="chat_pending_rejected">전달되지 않았습니다. 지금 보내기를 눌러 재시도하세요.</string>
<string name="chat_pending_send_now">지금 보내기</string>
<string name="chat_pending_send_now_warning">대기 중인 메시지를 지금 보내면 진행 중인 작업이 중단되고 하위 에이전트가 멈출 수 있습니다.</string>
<string name="chat_pending_send_again">다시 보내기</string>
<string name="chat_pending_attachments">첨부 파일도 함께 전송됩니다.</string>
</resources>
+20
View File
@@ -1509,6 +1509,26 @@
<string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
<string name="connection_setup_pending">正在等待 Hermes…</string>
<string name="chat_busy_mode_title">智能体工作时</string>
<string name="chat_busy_mode_desc">选择新消息如何送达当前聊天。空闲时正常发送。</string>
<string name="chat_busy_correct">修正当前轮次</string>
<string name="chat_busy_queue">排队等待下一轮</string>
<string name="chat_busy_guide">在下一个安全点提供指导</string>
<string name="chat_busy_interrupt">强制中断后发送</string>
<string name="chat_busy_interrupt_warning">强制中断可能停止子智能体。排队和指导会让当前工作继续。</string>
<string name="chat_busy_override">本条消息的发送方式</string>
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
<string name="chat_pending_sends">待发送消息</string>
<string name="chat_pending_queued">已在本机排队</string>
<string name="chat_pending_parked">停止后已暂停</string>
<string name="chat_pending_sending">正在发送…</string>
<string name="chat_pending_accepted">服务器已接收,等待写入聊天记录。再次发送可能造成重复。</string>
<string name="chat_pending_unknown">送达状态未知;再次发送可能重复</string>
<string name="chat_pending_rejected">未送达;点按“立即发送”重试</string>
<string name="chat_pending_send_now">立即发送</string>
<string name="chat_pending_send_now_warning">现在发送待处理消息可能会中断当前任务并停止子代理。</string>
<string name="chat_pending_send_again">再次发送</string>
<string name="chat_pending_attachments">附件将随这条消息发送。</string>
<string name="chat_action_timeline">时间线</string>
<string name="chat_history_window_latest">回到最新</string>
<string name="chat_history_window_more_both">此窗口之前和之后还有更多消息。</string>
+23
View File
@@ -194,6 +194,29 @@
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
<string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string>
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
<string name="chat_busy_mode_title">While the agent is working</string>
<string name="chat_busy_mode_desc">Choose how a new message reaches the current chat. Idle messages send normally.</string>
<string name="chat_busy_correct">Correct current turn</string>
<string name="chat_busy_queue">Queue next turn</string>
<string name="chat_busy_guide">Guide at next safe point</string>
<string name="chat_busy_interrupt">Hard interrupt, then send</string>
<string name="chat_busy_interrupt_warning">Hard interrupt can stop child agents. Queue and guide let current work continue.</string>
<string name="chat_busy_queue_action">Queue</string>
<string name="chat_busy_stop_and_send">Stop &amp; send</string>
<string name="chat_busy_more">More</string>
<string name="chat_busy_override">Send mode for this message</string>
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
<string name="chat_pending_sends">Pending messages</string>
<string name="chat_pending_queued">Queued on this device</string>
<string name="chat_pending_parked">Parked after Stop</string>
<string name="chat_pending_sending">Sending\u2026</string>
<string name="chat_pending_accepted">Accepted by server; awaiting transcript. Sending again may duplicate it.</string>
<string name="chat_pending_unknown">Delivery unknown; sending again may duplicate it</string>
<string name="chat_pending_rejected">Not delivered; choose Send now to retry</string>
<string name="chat_pending_send_now">Send now</string>
<string name="chat_pending_send_now_warning">Sending a pending message now may interrupt active work and stop child agents.</string>
<string name="chat_pending_send_again">Send again</string>
<string name="chat_pending_attachments">Attachments will be sent with this message.</string>
<string name="chat_tool_execution_error">Execution Error: %1$s</string>
<string name="chat_tool_exit_code">Exit Code: %1$d</string>
<string name="chat_tool_output_omitted">%1$d characters omitted from process output</string>
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
@@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest {
assertEquals(1000L, entity.timestamp)
}
@Test
fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() =
runTest {
repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s")
repository.persistMessage(
ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"),
"s",
)
repository.deleteMessage("local")
repository.deleteMessage("confirmed")
val rows = repository.loadMessages("s")
assertFalse(rows.any { it.id == "local" })
assertEquals("confirmed", rows.single().id)
}
@Test
fun persistMessages_upsertsAllToDao() =
runTest {
@@ -0,0 +1,48 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatQueuePresentationTest {
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
private fun pending(state: PendingSendState) =
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
@Test
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
val messages = listOf(message)
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
val receipts = listOf(pending(state))
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
assertEquals(listOf(message), messages)
assertEquals(state, receipts.single().state)
}
}
@Test
fun queuedMessageAppearsOnceWhenSendingStarts() {
val messages = listOf(message)
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
}
}
@Test
fun ordinarySendAndOtherMessagesAreUnchanged() {
val other = ChatMessage("other", MessageRole.USER, "Send now")
val messages = listOf(other, message)
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
@Test
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
val messages = listOf(message.copy(restId = "rest-session-12"))
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
}
@@ -0,0 +1,207 @@
package com.m57.hermescontrol.ui.chat
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendStoreTest {
private fun pending(
id: String,
text: String,
state: PendingSendState = PendingSendState.ACCEPTED,
) = PendingSend(id, "scope", "session", text, mode = BusySendMode.QUEUE, state = state)
private fun mappedHistoryAliases(
incoming: List<ChatMessage>,
existing: List<ChatMessage>,
): List<ChatMessage> =
incoming.zip(matchTranscriptMessages(incoming, existing)) { rest, match ->
match?.copy(restId = rest.canonicalRestId) ?: rest
}
@Test
fun durableAliasConfirmsAttachmentAndSteerNormalizedReceipt() {
val local = ChatMessage("send-1", MessageRole.USER, "See attachment")
val rest =
ChatMessage(
"rest-session-10",
MessageRole.USER,
"""
[OUT-OF-BAND USER MESSAGE — direct steering]
@file:files/agent-vault/hermes/attachments/note.txt
See attachment
[/OUT-OF-BAND USER MESSAGE]
""".trimIndent(),
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
mappedHistoryAliases(listOf(rest), listOf(local)),
listOf(pending("send-1", "See attachment")),
)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
val existing =
listOf(
ChatMessage("send-1", MessageRole.USER, "repeat"),
ChatMessage("send-2", MessageRole.USER, "repeat"),
)
val aliases =
mappedHistoryAliases(
listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
existing,
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun repeatedHydrationDoesNotConfirmAnotherDuplicateReceipt() {
val aliases =
mappedHistoryAliases(
listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
listOf(ChatMessage("send-1", MessageRole.USER, "repeat")),
)
val first =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
)
val remaining = listOf(pending("send-2", "repeat")).filterNot { it.id in first }
assertTrue(pendingSendIdsConfirmedByDurableAliases(aliases, remaining).isEmpty())
}
@Test
fun durableAliasDoesNotConfirmQueuedOrParkedReceipts() {
val aliases =
listOf(
ChatMessage("queued", MessageRole.USER, "same", restId = "rest-session-10"),
ChatMessage("parked", MessageRole.USER, "same", restId = "rest-session-11"),
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(
pending("queued", "same", PendingSendState.QUEUED),
pending("parked", "same", PendingSendState.PARKED),
),
)
assertTrue(confirmed.isEmpty())
}
@Test
fun promoteMovesTheChosenMessageAheadOfEarlierQueuedMessages() {
val store = ChatSendStore()
store.put(PendingSend("first", "scope", "session", "one", mode = BusySendMode.QUEUE))
store.put(PendingSend("second", "scope", "session", "two", mode = BusySendMode.QUEUE))
store.promote("second")
assertEquals(listOf("second", "first"), store.all().map { it.id })
}
@Test
fun recreationPreservesQueueAndQuarantinesAmbiguousSendsAcrossScopes() {
var saved: String? = null
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString("rows", null) } answers { saved }
every { prefs.edit() } returns editor
every { editor.putString("rows", any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
val first = ChatSendStore(prefs)
first.put(
PendingSend(
"queued",
"connection-a",
"session-a",
"later",
attachments = listOf(Attachment("file:///private/photo", "photo.png", "image/png", 3)),
mode = BusySendMode.QUEUE,
),
)
first.put(
PendingSend(
"sending",
"connection-b",
"session-b",
"maybe",
mode = BusySendMode.CORRECT,
state = PendingSendState.SENDING,
),
)
first.park("connection-a", "session-a")
val restored = ChatSendStore(prefs)
assertEquals(PendingSendState.PARKED, restored.all().single { it.id == "queued" }.state)
assertEquals(PendingSendState.UNKNOWN, restored.all().single { it.id == "sending" }.state)
assertEquals("connection-a", restored.all().single { it.id == "queued" }.scope)
assertEquals(
"file:///private/photo",
restored
.all()
.single { it.id == "queued" }
.attachments
.single()
.uri,
)
assertEquals("session-b", restored.all().single { it.id == "sending" }.sessionId)
restored.remove("queued")
assertTrue(ChatSendStore(prefs).all().none { it.id == "queued" })
assertEquals(1, ChatSendStore(prefs).all().size)
}
@Test
fun recreationQuarantinesAcceptedReceiptWithoutAutomaticReplay() {
var saved: String? = null
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString("rows", null) } answers { saved }
every { prefs.edit() } returns editor
every { editor.putString("rows", any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
ChatSendStore(prefs).put(
PendingSend(
"accepted",
"connection-a",
"session-a",
"possibly accepted",
mode = BusySendMode.QUEUE,
state = PendingSendState.ACCEPTED,
),
)
val restored = ChatSendStore(prefs).all().single()
assertEquals(PendingSendState.UNKNOWN, restored.state)
assertEquals(0, restored.attempts)
}
}
@@ -87,6 +87,10 @@ class ChatUpdateCommandTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -148,7 +152,15 @@ class ChatUpdateCommandTest {
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest).
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -185,7 +197,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown
@@ -224,7 +244,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate settles on success when the action exits cleanly`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -252,7 +280,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody())
File diff suppressed because it is too large. Load diff
@@ -94,6 +94,10 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -152,7 +156,15 @@ class SlashCommandDispatchRpcTest {
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao {
messages.values.removeAll { it.sessionId == sessionId }
}
override suspend fun deleteUnconfirmedMessage(id: String) {
if (messages[id]?.restId == null) messages.remove(id)
}
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
val existing = messages[message.id]
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.theme.ThemePreference
@@ -47,6 +48,17 @@ class SettingsViewModelTest {
return vm
}
@Test
fun busySendDefaultLoadsAndSaves() {
every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE
val viewModel = createViewModel()
assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode)
viewModel.onBusySendModeChange(BusySendMode.QUEUE)
assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode)
verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) }
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
@@ -71,6 +83,8 @@ class SettingsViewModelTest {
every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.setBusySendMode(any()) } returns Unit
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.isMessageStatsEnabled() } returns false