Let failed messages be discarded

This commit is contained in:
iris-ai committed 2026-09-15 19:03:44 -04:00
1 parent 3d1b1e304d
commit 33b130b6bb
4 files changed
+55 -1

No files matched your search

+3 -1
View File
@@ -454,7 +454,9 @@ response are stored per server and session on the phone, so leaving and reopenin
eat the only copy. Once the server accepts the request, the bubble remains in memory until the
provider event but the phone stops storing it: ownership has crossed to the server, whose transcript
and driver state survive the screen. Accepted queued messages remain the server transcript's fact
and are replayed from it on every device.
and are replayed from it on every device. A failed local bubble can be discarded after an explicit
confirmation that this removes the phone's only copy; otherwise the persistence that protects it
would also leave it on screen forever with no way out.
Reconciliation uses the first local message with the same text and attachments because the current
message route has no caller-supplied id. Identical sends are therefore consumed in wire order. A
@@ -65,6 +65,11 @@ internal fun markPendingAccepted(queued: List<QueuedMessage>, id: String): List<
if (message.local && message.id == id) message.copy(serverAccepted = true) else message
}
internal fun discardPendingMessage(
queued: List<QueuedMessage>,
id: String,
): List<QueuedMessage> = queued.filterNot { it.local && it.id == id }
/** Restores sends for which this phone has not yet seen a durable server event. */
internal fun loadPendingMessages(context: Context, key: String): List<QueuedMessage> {
val encoded =
@@ -373,6 +373,7 @@ fun SessionScreen(
val pendingKey =
remember(settings, address) { "${settings.host}:${settings.port}/${address.cachePath}" }
var queued by remember(pendingKey) { mutableStateOf(loadPendingMessages(context, pendingKey)) }
var discardingMessage by remember(pendingKey) { mutableStateOf<QueuedMessage?>(null) }
/**
* Changes the visible outbox and keeps only its not-yet-durable part across a screen reopen.
@@ -1569,6 +1570,12 @@ fun SessionScreen(
onTakeBack =
if (waiting.local) null
else ({ takeBack(waiting.id) }),
onDiscard =
if (waiting.local && waiting.refusal != null) {
{ discardingMessage = waiting }
} else {
null
},
)
}
}
@@ -2084,6 +2091,29 @@ fun SessionScreen(
},
)
}
discardingMessage?.let { message ->
AlertDialog(
onDismissRequest = { discardingMessage = null },
title = { Text("Discard unsent message?") },
text = {
Text(
"The server did not confirm this message. Discarding it removes this phone's " +
"only copy and cannot be undone."
)
},
confirmButton = {
TextButton(
onClick = {
replaceQueued(discardPendingMessage(queued, message.id))
discardingMessage = null
}
) {
Text("Discard", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { discardingMessage = null }) { Text("Keep") } },
)
}
if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being told
// is what pressing the button now would discard, and null until the walk of the directory
@@ -2202,6 +2232,7 @@ private fun UserChunkRow(
* before the session reads it. A local one is not, because its request may still be in flight.
* [refusal] is either that take-back refusal or the send's network failure. It is drawn here rather
* than with the screen's other errors because this is the message the failure belongs to.
* [onDiscard] is confirmed by the screen because a failed local bubble is the phone's only copy.
*
* A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one
* `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into.
@@ -2216,6 +2247,7 @@ private fun UserBubble(
pending: Boolean = false,
refusal: String? = null,
onTakeBack: (() -> Unit)? = null,
onDiscard: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxWidth()) {
Card(
@@ -2268,6 +2300,11 @@ private fun UserBubble(
color = MaterialTheme.colorScheme.error,
)
}
onDiscard?.let {
TextButton(onClick = it, modifier = Modifier.align(Alignment.End)) {
Text("Discard", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
@@ -48,6 +48,16 @@ class PendingMessagesTest {
assertTrue(queued.single().serverAccepted)
}
@Test
fun discarding_a_failed_send_removes_only_that_local_copy() {
val server = QueuedMessage("server-1", "already accepted", emptyList())
val queued = listOf(local(), local("keep this one").copy(id = "local-2"), server)
val discarded = discardPendingMessage(queued, "local-1")
assertEquals(listOf("local-2", "server-1"), discarded.map { it.id })
}
@Test
fun identical_messages_are_reconciled_one_at_a_time() {
val queued = listOf(local(), local().copy(id = "local-2"))