fix(#1427): recover sends past unresolved receipts (#1428)

This commit is contained in:
m57 authored and GitHub committed 2026-10-01 16:52:39 +03:00
1 parent 4294d7edff
commit 63105b7843
9 files changed
+491 -28

No files matched your search

@@ -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.ContextUsageChip
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.ReloginDialog
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
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val displayedMessages = timelineState.historyMessages ?: state.messages
val displayedMessages = transcriptState.messages
val lifecycleOwner = LocalLifecycleOwner.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(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
@@ -149,3 +149,18 @@ class ChatSendStore(
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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
@@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -2630,6 +2632,8 @@ class ChatViewModel(
) {
sendStore.update(messageId) { it.copy(state = state) }
publishPendingSends()
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
if (state != PendingSendState.SENDING) drainPendingQueue()
}
private fun removePendingSend(messageId: String) {
@@ -2672,7 +2676,7 @@ class ChatViewModel(
_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
private fun drainPendingQueue() {
val sessionId = _uiState.value.currentSessionId ?: return
@@ -2686,11 +2690,7 @@ class ChatViewModel(
if (queueDrainJob?.isActive == true) return
if (hasPendingSendReservation(scope, sessionId)) return
val rows = sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
if (rows.any {
it.state == PendingSendState.SENDING || it.state == PendingSendState.UNKNOWN ||
it.state == PendingSendState.ACCEPTED
}
) {
if (rows.any { it.state == PendingSendState.SENDING }) {
return
}
val next = rows.firstOrNull { it.state == PendingSendState.QUEUED } ?: return
@@ -2736,6 +2736,13 @@ class ChatViewModel(
"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. */
@Synchronized
fun sendQueuedNow(id: String) {
val row = sendStore.all().firstOrNull { it.id == id } ?: return
if (row.scope != sendScope() || row.sessionId != _uiState.value.currentSessionId ||
runtimeSessionId == null
runtimeSessionId == null || !_uiState.value.isSessionReady ||
wsClient.connectionStatus.value != ConnectionStatus.CONNECTED
) {
return
}
@@ -2822,7 +2845,7 @@ class ChatViewModel(
val others =
sendStore.all().any {
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) {
_uiState.update { it.copy(errorMessage = "Wait for the current send to settle") }
@@ -2942,12 +2965,7 @@ class ChatViewModel(
val hasOutstandingDelivery =
sendStore.all().any {
it.scope == sendScope() && it.sessionId == _uiState.value.currentSessionId &&
it.state in
setOf(
PendingSendState.SENDING,
PendingSendState.ACCEPTED,
PendingSendState.UNKNOWN,
)
it.state == PendingSendState.SENDING
} || hasPendingSendReservation(sendScope(), _uiState.value.currentSessionId)
val busy = wasStreaming || hasOutstandingDelivery
val selectedMode = modeOverride ?: _uiState.value.busySendMode
@@ -3825,8 +3843,7 @@ class ChatViewModel(
val busy =
mainTurnBusy ||
sendStore.all().any {
it.scope == sendScope() && it.sessionId == storageId &&
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
it.scope == sendScope() && it.sessionId == storageId && it.state == PendingSendState.SENDING
}
try {
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.VaultSaveLoginPromptUi
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. */
data class TranscriptUiState(
@@ -48,7 +49,8 @@ data class TranscriptUiState(
): TranscriptUiState {
val historical = timeline.isHistorical
return TranscriptUiState(
messages = timeline.historyMessages ?: chat.messages,
messages =
timeline.historyMessages ?: messagesWithoutUnconfirmedReceipts(chat.messages, chat.pendingSends),
streamingState = if (historical) StreamingState() else streaming,
isAgentTyping = !historical && chat.isAgentTyping,
typingEffectEnabled = !historical && chat.typingEffectEnabled,