Merge pull request #1429 from Hy4ri/codex/release-v2.0.1
chore(release): promote dev to main for v2.0.1
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
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 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.Assert.assertNull
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PendingSendRecoveryTest {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
private val uncertain =
|
||||||
|
PendingSend(
|
||||||
|
id = "uncertain",
|
||||||
|
scope = "test",
|
||||||
|
sessionId = "session",
|
||||||
|
text = "Possibly delivered",
|
||||||
|
mode = BusySendMode.QUEUE,
|
||||||
|
state = PendingSendState.UNKNOWN,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uncertainRetryRequiresExplicitConfirmation() {
|
||||||
|
var retried: String? = null
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(listOf(uncertain), true, false, { retried = it }, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").assertIsDisplayed().performClick()
|
||||||
|
compose.onNodeWithText("Possibly delivered").assertIsDisplayed()
|
||||||
|
compose.onNodeWithTag("pending_retry_uncertain").performClick()
|
||||||
|
compose.runOnIdle { assertNull(retried) }
|
||||||
|
compose.onNodeWithText("Cancel").assertIsDisplayed().performClick()
|
||||||
|
compose.runOnIdle { assertNull(retried) }
|
||||||
|
compose.onNodeWithTag("pending_retry_uncertain").performClick()
|
||||||
|
compose.onNodeWithTag("pending_retry_confirm").performClick()
|
||||||
|
compose.runOnIdle { assertEquals("uncertain", retried) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disconnectedSessionCanInspectAndDismissButCannotRetry() {
|
||||||
|
var dismissed: String? = null
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(listOf(uncertain), false, false, {}, { dismissed = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").performClick()
|
||||||
|
compose.onNodeWithTag("pending_retry_uncertain").assertIsNotEnabled()
|
||||||
|
compose.onNodeWithTag("pending_discard_uncertain").performClick()
|
||||||
|
compose.runOnIdle { assertNull(dismissed) }
|
||||||
|
compose
|
||||||
|
.onNodeWithText(
|
||||||
|
"Remove this message from the device queue? This does not cancel work or delete messages already received by the server.",
|
||||||
|
).assertIsDisplayed()
|
||||||
|
compose.onNodeWithTag("pending_discard_confirm").performClick()
|
||||||
|
compose.runOnIdle { assertEquals("uncertain", dismissed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyOutboxDoesNotAddAComposerControl() {
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(emptyList(), true, false, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(
|
||||||
|
listOf(uncertain.copy(text = "Long diagnostic text ".repeat(200))),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").performClick()
|
||||||
|
compose.onNodeWithTag("pending_retry_uncertain").performScrollTo().performClick()
|
||||||
|
compose.onNodeWithTag("pending_retry_confirm").assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Cancel").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ import com.m57.hermescontrol.ui.chat.components.ConnectionSetupSheet
|
|||||||
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog
|
import com.m57.hermescontrol.ui.chat.components.MediaViewerDialog
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.PendingSendRecovery
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||||
@@ -180,7 +181,7 @@ fun ChatScreen(
|
|||||||
// Snapshot-backed search state — read directly so only the scopes that
|
// Snapshot-backed search state — read directly so only the scopes that
|
||||||
// read its fields recompose on search changes (bar, matched bubbles).
|
// read its fields recompose on search changes (bar, matched bubbles).
|
||||||
val searchState = viewModel.searchState
|
val searchState = viewModel.searchState
|
||||||
val displayedMessages = timelineState.historyMessages ?: state.messages
|
val displayedMessages = transcriptState.messages
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -851,6 +852,16 @@ fun ChatScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!timelineState.isHistorical) {
|
||||||
|
PendingSendRecovery(
|
||||||
|
sends = state.pendingSends,
|
||||||
|
canSend = state.isConnected && state.isSessionReady,
|
||||||
|
mainTurnBusy = state.isMainTurnBusy,
|
||||||
|
onSendAgain = viewModel::sendQueuedNow,
|
||||||
|
onDiscard = viewModel::discardPendingSend,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = inputFieldValue,
|
inputFieldValue = inputFieldValue,
|
||||||
onInputChange = { inputFieldValue = it },
|
onInputChange = { inputFieldValue = it },
|
||||||
|
|||||||
@@ -149,3 +149,18 @@ class ChatSendStore(
|
|||||||
rows = next
|
rows = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #1427: uncertain receipt bubbles live in recovery UI, not after their server transcript counterpart. */
|
||||||
|
internal fun messagesWithoutUnconfirmedReceipts(
|
||||||
|
messages: List<ChatMessage>,
|
||||||
|
pending: List<PendingSend>,
|
||||||
|
): List<ChatMessage> {
|
||||||
|
val recoveryIds =
|
||||||
|
pending
|
||||||
|
.filter {
|
||||||
|
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN ||
|
||||||
|
it.state == PendingSendState.REJECTED
|
||||||
|
}.mapTo(mutableSetOf()) { it.id }
|
||||||
|
if (recoveryIds.isEmpty()) return messages
|
||||||
|
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import com.m57.hermescontrol.ui.common.ActionProgressController
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -2630,6 +2632,8 @@ class ChatViewModel(
|
|||||||
) {
|
) {
|
||||||
sendStore.update(messageId) { it.copy(state = state) }
|
sendStore.update(messageId) { it.copy(state = state) }
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
|
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
||||||
|
if (state != PendingSendState.SENDING) drainPendingQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removePendingSend(messageId: String) {
|
private fun removePendingSend(messageId: String) {
|
||||||
@@ -2672,7 +2676,7 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) }
|
_uiState.update { it.copy(errorMessage = reason, isAgentTyping = mainTurnBusy) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send one locally queued turn only after the previous receipt has reached REST history. */
|
/** #1427: serialize submissions, not durable-history reconciliation; never replay uncertain rows. */
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun drainPendingQueue() {
|
private fun drainPendingQueue() {
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
val sessionId = _uiState.value.currentSessionId ?: return
|
||||||
@@ -2686,11 +2690,7 @@ class ChatViewModel(
|
|||||||
if (queueDrainJob?.isActive == true) return
|
if (queueDrainJob?.isActive == true) return
|
||||||
if (hasPendingSendReservation(scope, sessionId)) return
|
if (hasPendingSendReservation(scope, sessionId)) return
|
||||||
val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
||||||
if (rows.any {
|
if (rows.any { it.state == PendingSendState.SENDING }) {
|
||||||
it.state == PendingSendState.SENDING || it.state == PendingSendState.UNKNOWN ||
|
|
||||||
it.state == PendingSendState.ACCEPTED
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return
|
val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return
|
||||||
@@ -2736,6 +2736,13 @@ class ChatViewModel(
|
|||||||
"Could not retain queued attachment"
|
"Could not retain queued attachment"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
if (queueDrainJob === currentCoroutineContext()[Job]) {
|
||||||
|
queueDrainJob = null
|
||||||
|
// Preparation can restore the same known-unsent row. Leave it for explicit recovery.
|
||||||
|
val restored = sendStore.all().any { it.id == next.id && it.state == PendingSendState.QUEUED }
|
||||||
|
if (currentCoroutineContext().isActive && !restored) drainPendingQueue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2794,12 +2801,28 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #1427: explicit recovery can dismiss an uncertain receipt without guessing delivery or resending it. */
|
||||||
|
@Synchronized
|
||||||
|
fun discardPendingSend(id: String) {
|
||||||
|
val row = sendStore.all().firstOrNull { it.id == id } ?: return
|
||||||
|
if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId ||
|
||||||
|
row.state == PendingSendState.SENDING ||
|
||||||
|
synchronized(pendingSendReservationLock) { pendingSendReservations.any { it.pending.id == id } }
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeUnconfirmedBubble(id)
|
||||||
|
removePendingSend(id)
|
||||||
|
drainPendingQueue()
|
||||||
|
}
|
||||||
|
|
||||||
/** A manual promotion is the only path that may retry an uncertain send. */
|
/** A manual promotion is the only path that may retry an uncertain send. */
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun sendQueuedNow(id: String) {
|
fun sendQueuedNow(id: String) {
|
||||||
val row = sendStore.all().firstOrNull { it.id == id } ?: return
|
val row = sendStore.all().firstOrNull { it.id == id } ?: return
|
||||||
if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId ||
|
if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId ||
|
||||||
runtimeSessionId == null
|
runtimeSessionId == null || !_uiState.value.isSessionReady ||
|
||||||
|
wsClient.connectionStatus.value != ConnectionStatus.CONNECTED
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2822,7 +2845,7 @@ class ChatViewModel(
|
|||||||
val others =
|
val others =
|
||||||
sendStore.all().any {
|
sendStore.all().any {
|
||||||
it.id != id && it.scope == row.scope && it.sessionId == row.sessionId &&
|
it.id != id && it.scope == row.scope && it.sessionId == row.sessionId &&
|
||||||
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
it.state == PendingSendState.SENDING
|
||||||
}
|
}
|
||||||
if (others) {
|
if (others) {
|
||||||
_uiState.update { it.copy(errorMessage = "Wait for the current send to settle") }
|
_uiState.update { it.copy(errorMessage = "Wait for the current send to settle") }
|
||||||
@@ -2942,12 +2965,7 @@ class ChatViewModel(
|
|||||||
val hasOutstandingDelivery =
|
val hasOutstandingDelivery =
|
||||||
sendStore.all().any {
|
sendStore.all().any {
|
||||||
it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId &&
|
it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId &&
|
||||||
it.state in
|
it.state == PendingSendState.SENDING
|
||||||
setOf(
|
|
||||||
PendingSendState.SENDING,
|
|
||||||
PendingSendState.ACCEPTED,
|
|
||||||
PendingSendState.UNKNOWN,
|
|
||||||
)
|
|
||||||
} || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId)
|
} || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId)
|
||||||
val busy = wasStreaming || hasOutstandingDelivery
|
val busy = wasStreaming || hasOutstandingDelivery
|
||||||
val selectedMode = modeOverride ?: _uiState.value.busySendMode
|
val selectedMode = modeOverride ?: _uiState.value.busySendMode
|
||||||
@@ -3825,8 +3843,7 @@ class ChatViewModel(
|
|||||||
val busy =
|
val busy =
|
||||||
mainTurnBusy ||
|
mainTurnBusy ||
|
||||||
sendStore.all().any {
|
sendStore.all().any {
|
||||||
it.scope == sendScope() && it.sessionId == storageId &&
|
it.scope == sendScope() && it.sessionId == storageId && it.state == PendingSendState.SENDING
|
||||||
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
sendStore.put(
|
sendStore.put(
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
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
|
||||||
|
|
||||||
|
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PendingSendRecovery(
|
||||||
|
sends: List<PendingSend>,
|
||||||
|
canSend: Boolean,
|
||||||
|
mainTurnBusy: Boolean,
|
||||||
|
onSendAgain: (String) -> Unit,
|
||||||
|
onDiscard: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (sends.isEmpty()) return
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
var retryId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var discardId by remember { mutableStateOf<String?>(null) }
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val submissionInFlight = sends.any { it.state == PendingSendState.SENDING }
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
open = true
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
|
||||||
|
) {
|
||||||
|
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size))
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
ModalBottomSheet(onDismissRequest = { open = false }) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_pending_sends),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
||||||
|
items(sends, key = { it.id }) { send ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(send.text, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when (send.state) {
|
||||||
|
PendingSendState.QUEUED -> R.string.chat_pending_queued
|
||||||
|
PendingSendState.PARKED -> R.string.chat_pending_parked
|
||||||
|
PendingSendState.SENDING -> R.string.chat_pending_sending
|
||||||
|
PendingSendState.ACCEPTED -> R.string.chat_pending_accepted
|
||||||
|
PendingSendState.UNKNOWN -> R.string.chat_pending_unknown
|
||||||
|
PendingSendState.REJECTED -> R.string.chat_pending_rejected
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (send.attachments.isNotEmpty()) {
|
||||||
|
Text(stringResource(R.string.chat_pending_attachments))
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (mainTurnBusy || send.state == PendingSendState.ACCEPTED ||
|
||||||
|
send.state == PendingSendState.UNKNOWN
|
||||||
|
) {
|
||||||
|
retryId = send.id
|
||||||
|
} else {
|
||||||
|
onSendAgain(send.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = canSend && !submissionInFlight && !send.requiresAttachmentRecovery,
|
||||||
|
modifier = Modifier.heightIn(min = 48.dp).testTag("pending_retry_${send.id}"),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (send.state == PendingSendState.ACCEPTED ||
|
||||||
|
send.state == PendingSendState.UNKNOWN
|
||||||
|
) {
|
||||||
|
R.string.chat_pending_send_again
|
||||||
|
} else {
|
||||||
|
R.string.chat_pending_send_now
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { discardId = send.id },
|
||||||
|
enabled = send.state != PendingSendState.SENDING,
|
||||||
|
modifier = Modifier.heightIn(min = 48.dp).testTag("pending_discard_${send.id}"),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.chat_pending_discard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sends.firstOrNull { it.id == retryId }?.let { send ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { retryId = null },
|
||||||
|
title = { Text(stringResource(R.string.chat_pending_send_again)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(send.text, maxLines = 6, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (send.state == PendingSendState.ACCEPTED || send.state == PendingSendState.UNKNOWN) {
|
||||||
|
Text(stringResource(R.string.chat_pending_unknown))
|
||||||
|
}
|
||||||
|
if (mainTurnBusy) Text(stringResource(R.string.chat_pending_send_now_warning))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
retryId = null
|
||||||
|
onSendAgain(send.id)
|
||||||
|
}, enabled = canSend && !submissionInFlight, modifier = Modifier.testTag("pending_retry_confirm")) {
|
||||||
|
Text(stringResource(R.string.chat_pending_send_again))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { retryId = null }) { Text(stringResource(R.string.system_confirm_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sends.firstOrNull { it.id == discardId }?.let { send ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { discardId = null },
|
||||||
|
title = { Text(stringResource(R.string.chat_pending_discard)) },
|
||||||
|
text = { Text(stringResource(R.string.chat_pending_discard_warning)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
discardId = null
|
||||||
|
onDiscard(send.id)
|
||||||
|
},
|
||||||
|
enabled = send.state != PendingSendState.SENDING,
|
||||||
|
modifier =
|
||||||
|
Modifier.testTag(
|
||||||
|
"pending_discard_confirm",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.chat_pending_discard))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { discardId = null }) { Text(stringResource(R.string.system_confirm_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.ui.chat.StreamingState
|
|||||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||||
|
import com.m57.hermescontrol.ui.chat.messagesWithoutUnconfirmedReceipts
|
||||||
|
|
||||||
/** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */
|
/** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */
|
||||||
data class TranscriptUiState(
|
data class TranscriptUiState(
|
||||||
@@ -48,7 +49,8 @@ data class TranscriptUiState(
|
|||||||
): TranscriptUiState {
|
): TranscriptUiState {
|
||||||
val historical = timeline.isHistorical
|
val historical = timeline.isHistorical
|
||||||
return TranscriptUiState(
|
return TranscriptUiState(
|
||||||
messages = timeline.historyMessages ?: chat.messages,
|
messages =
|
||||||
|
timeline.historyMessages ?: messagesWithoutUnconfirmedReceipts(chat.messages, chat.pendingSends),
|
||||||
streamingState = if (historical) StreamingState() else streaming,
|
streamingState = if (historical) StreamingState() else streaming,
|
||||||
isAgentTyping = !historical && chat.isAgentTyping,
|
isAgentTyping = !historical && chat.isAgentTyping,
|
||||||
typingEffectEnabled = !historical && chat.typingEffectEnabled,
|
typingEffectEnabled = !historical && chat.typingEffectEnabled,
|
||||||
|
|||||||
@@ -219,6 +219,12 @@
|
|||||||
<string name="chat_busy_override">Send mode for this message</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_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_sends">Pending messages</string>
|
||||||
|
<plurals name="chat_pending_count">
|
||||||
|
<item quantity="one">%d pending message</item>
|
||||||
|
<item quantity="other">%d pending messages</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="chat_pending_discard">Dismiss</string>
|
||||||
|
<string name="chat_pending_discard_warning">Remove this message from the device queue? This does not cancel work or delete messages already received by the server.</string>
|
||||||
<string name="chat_pending_queued">Queued on this device</string>
|
<string name="chat_pending_queued">Queued on this device</string>
|
||||||
<string name="chat_pending_parked">Parked after Stop</string>
|
<string name="chat_pending_parked">Parked after Stop</string>
|
||||||
<string name="chat_pending_sending">Sending\u2026</string>
|
<string name="chat_pending_sending">Sending\u2026</string>
|
||||||
|
|||||||
@@ -204,4 +204,28 @@ class ChatSendStoreTest {
|
|||||||
assertEquals(PendingSendState.UNKNOWN, restored.state)
|
assertEquals(PendingSendState.UNKNOWN, restored.state)
|
||||||
assertEquals(0, restored.attempts)
|
assertEquals(0, restored.attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uncertainReceiptGhostsAreHiddenWithoutConfirmingChangedServerIdentity() {
|
||||||
|
val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10)
|
||||||
|
val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20)
|
||||||
|
val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21)
|
||||||
|
val receipt = pending("send-1", "repeat").copy(userRowId = 10)
|
||||||
|
val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(server.id, reply.id),
|
||||||
|
messagesWithoutUnconfirmedReceipts(merged, listOf(receipt)).map { it.id },
|
||||||
|
)
|
||||||
|
assertTrue(pendingSendIdsConfirmedByDurableAliases(merged, listOf(receipt)).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun queuedBubblesAndConfirmedAliasesStayInTheTranscript() {
|
||||||
|
val queued = ChatMessage("queued", MessageRole.USER, "later")
|
||||||
|
val confirmed = ChatMessage("confirmed", MessageRole.USER, "delivered", restId = "rest-session-10")
|
||||||
|
val pending = listOf(pending("queued", "later", PendingSendState.QUEUED), pending("confirmed", "delivered"))
|
||||||
|
|
||||||
|
assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3844,7 +3844,7 @@ class ChatViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interruptWithUnknownDeliveryQueuesWithoutInterrupting() =
|
fun interruptWithUnknownDeliverySubmitsWithoutReplayingEarlierPrompt() =
|
||||||
runTest {
|
runTest {
|
||||||
val (viewModel, sessionId) = createViewModelWithSession()
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers {
|
every { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), false) } answers {
|
||||||
@@ -3863,8 +3863,8 @@ class ChatViewModelTest {
|
|||||||
val replacement =
|
val replacement =
|
||||||
viewModel.uiState.value.pendingSends
|
viewModel.uiState.value.pendingSends
|
||||||
.single { it.text == "Replacement" }
|
.single { it.text == "Replacement" }
|
||||||
assertEquals(BusySendMode.QUEUE, replacement.mode)
|
assertEquals(BusySendMode.CORRECT, replacement.mode)
|
||||||
assertEquals(PendingSendState.QUEUED, replacement.state)
|
assertEquals(PendingSendState.SENDING, replacement.state)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
PendingSendState.UNKNOWN,
|
PendingSendState.UNKNOWN,
|
||||||
viewModel.uiState.value.pendingSends
|
viewModel.uiState.value.pendingSends
|
||||||
@@ -3872,11 +3872,12 @@ class ChatViewModelTest {
|
|||||||
.state,
|
.state,
|
||||||
)
|
)
|
||||||
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
|
verify(exactly = 1) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Uncertain", any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun interruptWithAcceptedDeliveryQueuesWithoutInterrupting() =
|
fun interruptWithAcceptedDeliverySubmitsWithoutReplayingEarlierPrompt() =
|
||||||
runTest {
|
runTest {
|
||||||
val (viewModel, sessionId) = createViewModelWithSession()
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers {
|
every { HermesWsClient.sendMessage(sessionId, "Accepted", any(), false) } answers {
|
||||||
@@ -3895,8 +3896,8 @@ class ChatViewModelTest {
|
|||||||
val replacement =
|
val replacement =
|
||||||
viewModel.uiState.value.pendingSends
|
viewModel.uiState.value.pendingSends
|
||||||
.single { it.text == "Replacement" }
|
.single { it.text == "Replacement" }
|
||||||
assertEquals(BusySendMode.QUEUE, replacement.mode)
|
assertEquals(BusySendMode.CORRECT, replacement.mode)
|
||||||
assertEquals(PendingSendState.QUEUED, replacement.state)
|
assertEquals(PendingSendState.SENDING, replacement.state)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
PendingSendState.ACCEPTED,
|
PendingSendState.ACCEPTED,
|
||||||
viewModel.uiState.value.pendingSends
|
viewModel.uiState.value.pendingSends
|
||||||
@@ -3904,7 +3905,8 @@ class ChatViewModelTest {
|
|||||||
.state,
|
.state,
|
||||||
)
|
)
|
||||||
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
|
verify(exactly = 1) { HermesWsClient.sendMessage(any(), "Replacement", any(), any()) }
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Accepted", any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -4149,7 +4151,7 @@ class ChatViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendNowWaitsForEarlierAcceptedTurnToReachRestHistory() =
|
fun acceptedReceiptReleasesNextQueuedSubmissionWithoutWaitingForHistory() =
|
||||||
runTest {
|
runTest {
|
||||||
val (viewModel, sessionId) = createViewModelWithSession()
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers {
|
every { HermesWsClient.sendMessage(sessionId, "First", any(), true) } answers {
|
||||||
@@ -4174,12 +4176,13 @@ class ChatViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
PendingSendState.QUEUED,
|
PendingSendState.SENDING,
|
||||||
viewModel.uiState.value.pendingSends
|
viewModel.uiState.value.pendingSends
|
||||||
.single { it.id == second.id }
|
.single { it.id == second.id }
|
||||||
.state,
|
.state,
|
||||||
)
|
)
|
||||||
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
verify(exactly = 0) { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Second", any(), true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -10536,4 +10539,107 @@ class ChatViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertTrue(vm.uiState.value.canInterrupt)
|
assertTrue(vm.uiState.value.canInterrupt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() =
|
||||||
|
runTest {
|
||||||
|
val store = ChatSendStore()
|
||||||
|
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||||
|
every { HermesWsClient.sendMessage(session, "probe-first", any(), any()) } answers {
|
||||||
|
arg<((String) -> Unit)?>(2)?.invoke("probe-submit")
|
||||||
|
"probe-submit"
|
||||||
|
}
|
||||||
|
assertTrue(vm.sendMessage("probe-first"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
mockEventsFlow.emit(WsEvent.RpcResult("probe-submit", mapOf("status" to "streaming", "user_row_id" to 10)))
|
||||||
|
advanceUntilIdle()
|
||||||
|
coEvery { ApiClient.hermesApi.getSessionMessages(session, any(), any(), any(), any()) } returns
|
||||||
|
Response.success(
|
||||||
|
SessionMessagesResponse(
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
SessionMessage(id = 20, role = "user", content = JsonPrimitive("probe-first")),
|
||||||
|
SessionMessage(id = 21, role = "assistant", content = JsonPrimitive("probe-reply")),
|
||||||
|
),
|
||||||
|
pagination = PaginationInfo(order = "latest"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageStart(session))
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageComplete("probe-reply", session))
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.syncCurrentSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.count { it.role == MessageRole.USER && it.content == "probe-first" },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"probe-first",
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||||
|
assertTrue(vm.uiState.value.isSessionReady)
|
||||||
|
assertTrue(vm.sendMessage("probe-second"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.any { it.content == "probe-second" },
|
||||||
|
)
|
||||||
|
assertEquals(PendingSendState.SENDING, store.all().single { it.text == "probe-second" }.state)
|
||||||
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
|
||||||
|
assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
vm.transcriptState.value.messages
|
||||||
|
.count { it.content == "probe-first" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timeoutReleasesQueuedPromptWithoutReplayingUncertainSend() =
|
||||||
|
runTest {
|
||||||
|
val store = ChatSendStore()
|
||||||
|
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||||
|
every { HermesWsClient.sendMessage(session, "uncertain-first", any(), any()) } answers {
|
||||||
|
arg<((String) -> Unit)?>(2)?.invoke("uncertain-submit")
|
||||||
|
"uncertain-submit"
|
||||||
|
}
|
||||||
|
assertTrue(vm.sendMessage("uncertain-first"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(vm.sendMessage("queued-second"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(PendingSendState.QUEUED, store.all().single { it.text == "queued-second" }.state)
|
||||||
|
vm.expireOutgoingRequest("uncertain-submit")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "uncertain-first" }.state)
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "queued-second", any(), true) }
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "uncertain-first", any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dismissUnknownReceiptDoesNotResendAndCannotDismissInFlight() =
|
||||||
|
runTest {
|
||||||
|
val store = ChatSendStore()
|
||||||
|
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||||
|
assertTrue(vm.sendMessage("dismiss-first"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
val receipt = store.all().single()
|
||||||
|
vm.discardPendingSend(receipt.id)
|
||||||
|
assertEquals(PendingSendState.SENDING, store.all().single().state)
|
||||||
|
store.update(receipt.id) { it.copy(state = PendingSendState.UNKNOWN) }
|
||||||
|
vm.discardPendingSend(receipt.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(store.all().isEmpty())
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.none { it.id == receipt.id },
|
||||||
|
)
|
||||||
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "dismiss-first", any(), any()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user