fix(#1427): keep accepted prompts visible during live turns (#1430)

This commit is contained in:
m57 authored and GitHub committed 2026-10-01 20:17:05 +03:00
1 parent 63105b7843
commit 1cef9e7ebb
13 files changed
+332 -14

No files matched your search

@@ -859,6 +859,8 @@ fun ChatScreen(
mainTurnBusy = state.isMainTurnBusy,
onSendAgain = viewModel::sendQueuedNow,
onDiscard = viewModel::discardPendingSend,
onOpenAttachment = viewModel::openAttachment,
onImageClick = { viewingImage = it },
)
}
@@ -158,9 +158,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
val recoveryIds =
pending
.filter {
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN ||
it.state == PendingSendState.REJECTED
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 }
}
/** #1427: normal submission/acceptance stays in the transcript, not in recovery. */
internal val PendingSend.needsRecovery: Boolean
get() = state != PendingSendState.SENDING && state != PendingSendState.ACCEPTED
@@ -543,6 +543,7 @@ class ChatViewModel(
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
private val outgoingRequestById = ConcurrentHashMap<String, String>()
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
private val pendingSendReservationLock = Any()
@@ -1702,6 +1703,17 @@ class ChatViewModel(
}
when {
accepted -> {
// #1427: a gateway-queued prompt belongs to a future turn, not the
// completion currently on screen. Keep it live until that turn is verified.
val receipt = sendStore.all().firstOrNull { it.id == messageId }
acceptedTurnEpochById[messageId] =
if (status == "queued" ||
(!mainTurnBusy && (receipt == null || lastMainCompletionAt < receipt.createdAt))
) {
mainTurnEpoch + 1
} else {
mainTurnEpoch
}
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
sendStore.update(messageId) { it.copy(userRowId = rowId) }
@@ -2631,12 +2643,14 @@ class ChatViewModel(
state: PendingSendState,
) {
sendStore.update(messageId) { it.copy(state = state) }
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
publishPendingSends()
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
if (state != PendingSendState.SENDING) drainPendingQueue()
}
private fun removePendingSend(messageId: String) {
acceptedTurnEpochById.remove(messageId)
sendStore.remove(messageId)
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
publishPendingSends()
@@ -5648,6 +5662,17 @@ class ChatViewModel(
val requestSequence = activeHydrationRequestSequence
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
val useLatest = latestPaging
// #1427: acceptance alone is normal live UX. Only a completed turn followed by
// successful history verification can move its still-unmatched receipts into recovery.
// Capture the rows before suspending so a later prompt/retry cannot be quarantined.
val completionAt = lastMainCompletionAt
val completedTurnEpoch = mainTurnEpoch
val unverifiedAccepted =
sendStore.all().filter {
it.scope == sendScope() && it.sessionId == sessionId &&
it.state == PendingSendState.ACCEPTED && it.createdAt <= completionAt &&
acceptedTurnEpochById[it.id]?.let { epoch -> epoch <= completedTurnEpoch } == true
}
val nextOffset =
if (useLatest) {
0
@@ -5682,6 +5707,13 @@ class ChatViewModel(
)
} ?: return@launch
persistHistoryPage(page, sessionId)
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
unverifiedAccepted.forEach { receipt ->
if (sendStore.all().any { it == receipt }) {
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
}
}
}
// Never derive the older cursor from displayed rows or reset it to this latest page.
// Append-only growth shifts from-end offsets toward newer rows: the next older
// request may overlap, but stable IDs remove echoes without skipping any history.
@@ -81,6 +81,7 @@ fun UserBubble(
messageStatsEnabled: Boolean = false,
showUserMessageTokens: Boolean = true,
modifier: Modifier = Modifier,
pendingSendState: PendingSendState? = null,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val maxBubbleWidth = screenWidth * 0.80f
@@ -182,6 +183,23 @@ fun UserBubble(
canSave = canSaveAttachment,
onImageClick = onImageClick,
)
// #1427: keep the live text and media visible with an honest delivery label.
if (pendingSendState == PendingSendState.SENDING ||
pendingSendState == PendingSendState.ACCEPTED
) {
Text(
stringResource(
if (pendingSendState == PendingSendState.SENDING) {
R.string.chat_pending_sending
} else {
R.string.chat_send_accepted
},
),
color = userBubbleTextColor,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.testTag("user_send_status_${message.id}"),
)
}
if (!message.isStreaming) {
FlowRow(
modifier =
@@ -27,8 +27,12 @@ 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.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.InlineAttachment
import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.needsRecovery
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
@OptIn(ExperimentalMaterial3Api::class)
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
mainTurnBusy: Boolean,
onSendAgain: (String) -> Unit,
onDiscard: (String) -> Unit,
onOpenAttachment: (Attachment) -> Unit = {},
onImageClick: (ImageViewerModel) -> Unit = {},
) {
if (sends.isEmpty()) return
val recoverySends = sends.filter { it.needsRecovery }
if (recoverySends.isEmpty()) return
var open by remember { mutableStateOf(false) }
var retryId by remember { mutableStateOf<String?>(null) }
var discardId by remember { mutableStateOf<String?>(null) }
@@ -53,7 +60,7 @@ fun PendingSendRecovery(
},
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
) {
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size))
Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size))
}
if (open) {
ModalBottomSheet(onDismissRequest = { open = false }) {
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
items(sends, key = { it.id }) { send ->
items(recoverySends, key = { it.id }) { send ->
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (send.attachments.isNotEmpty()) {
Text(stringResource(R.string.chat_pending_attachments))
send.attachments.forEach { attachment ->
if (attachment.isImage || attachment.isAudio || attachment.isVideo) {
Text(attachment.name, style = MaterialTheme.typography.bodyMedium)
}
InlineAttachment(
attachment = attachment,
textColor = MaterialTheme.colorScheme.onSurface,
onOpen = onOpenAttachment,
onSave = {},
savingPath = null,
openingPath = null,
canSave = false,
onImageClick = onImageClick,
)
}
Row {
TextButton(
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
}
}
}
sends.firstOrNull { it.id == retryId }?.let { send ->
recoverySends.firstOrNull { it.id == retryId }?.let { send ->
AlertDialog(
onDismissRequest = { retryId = null },
title = { Text(stringResource(R.string.chat_pending_send_again)) },
@@ -151,7 +170,7 @@ fun PendingSendRecovery(
},
)
}
sends.firstOrNull { it.id == discardId }?.let { send ->
recoverySends.firstOrNull { it.id == discardId }?.let { send ->
AlertDialog(
onDismissRequest = { discardId = null },
title = { Text(stringResource(R.string.chat_pending_discard)) },
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.SearchTarget
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.UserBubble
@@ -311,6 +312,7 @@ fun FullBleedChatList(
onImageClick = actions.onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
pendingSendState = transcript.pendingSendStates[userMessage.id],
)
if (userMessage.reactions.isNotEmpty()) {
MessageReactionChips(
@@ -561,6 +563,7 @@ private fun renderUserBubble(
onImageClick: (ImageViewerModel) -> Unit,
messageStatsEnabled: Boolean,
showUserMessageTokens: Boolean,
pendingSendState: PendingSendState?,
) {
UserBubble(
message = message,
@@ -574,5 +577,6 @@ private fun renderUserBubble(
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
pendingSendState = pendingSendState,
)
}
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
@@ -37,6 +38,7 @@ data class TranscriptUiState(
val isCompressing: Boolean,
val compressionStatus: String?,
val speakingMessageId: String?,
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
) {
companion object {
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
@@ -76,6 +78,8 @@ data class TranscriptUiState(
isCompressing = !historical && chat.isCompressing,
compressionStatus = chat.compressionStatus.takeUnless { historical },
speakingMessageId = speakingMessageId,
pendingSendStates =
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
)
}
}