diff --git a/PLAN.md b/PLAN.md index 7307322..f69d41f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingMessages.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingMessages.kt index 56584db..eead638 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingMessages.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingMessages.kt @@ -65,6 +65,11 @@ internal fun markPendingAccepted(queued: List, id: String): List< if (message.local && message.id == id) message.copy(serverAccepted = true) else message } +internal fun discardPendingMessage( + queued: List, + id: String, +): List = 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 { val encoded = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 50327c8..7160936 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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(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) + } + } } } } diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/PendingMessagesTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/PendingMessagesTest.kt index 4f3558f..23c1369 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/PendingMessagesTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/PendingMessagesTest.kt @@ -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"))