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:
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) }
|
||||
}
|
||||
}
|
||||
+165
-6
@@ -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()
|
||||
|
||||
+98
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-18
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
Reference in new issue
Block a user