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