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,
|
||||
|
||||
Reference in new issue
Block a user