Skip to content

Commit 199162f

Browse files
committed
fix(chat): make archive restore safe, and cap what one page paints
Follow-ups to the session-loss and long-session fixes: - Restoring archived messages is now a menu action with a count, not dead code and not an automatic reload. It slots rows back by created_at instead of appending them after everything typed since, and it retires only the rows it restored — the session-wide archive wipe was deleting the pre-edit history of messages that are still live. - Deleting from an assistant reply anchors on the visible user bubble, including an image-only prompt. Skipping it because its text is empty cut the previous turn as well. - The "load older" fallback and the split-turn prefix share the cold-open row cap. One tap no longer materialises every earlier row, and one huge tool turn can no longer undo the render window. - Chat image thumbnails, the fullscreen preview and the composer chip decode through the display-size ceiling the markdown renderer already uses. The OOM was per-bitmap, not per-row.
1 parent e2cc1fe commit 199162f

28 files changed

Lines changed: 449 additions & 61 deletions

File tree

‎src/android/app/src/main/java/com/openminis/app/data/db/ChatDao.kt‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ data class MessageSortAnchor(
8383
val role: String,
8484
)
8585

86+
/** (id, created_at, sort_order) — the meta archive restore reorders by. */
87+
data class MessageSortMetaRow(
88+
val id: String,
89+
@ColumnInfo(name = "created_at") val createdAt: Long,
90+
@ColumnInfo(name = "sort_order") val sortOrder: Int,
91+
)
92+
8693
data class MessagePreviewRow(
8794
val role: String,
8895
val preview: String?,
@@ -646,6 +653,17 @@ interface ChatDao {
646653
@Query("SELECT sort_order FROM messages WHERE id = :id")
647654
suspend fun sortOrderForMessageId(id: String): Int?
648655

656+
/** Meta-only projection for [ChatRepository.restoreArchivedVersions]. */
657+
@Query(
658+
"SELECT id, created_at, sort_order FROM messages " +
659+
"WHERE session_id = :sessionId ORDER BY sort_order ASC"
660+
)
661+
suspend fun sortMetaForSession(sessionId: String): List<MessageSortMetaRow>
662+
663+
/** Rewrites one row's sequence slot. Used by the restore re-insertion. */
664+
@Query("UPDATE messages SET sort_order = :sortOrder WHERE id = :id")
665+
suspend fun updateSortOrder(id: String, sortOrder: Int)
666+
649667
// [T-error-persist-android] Write/clear the terminal error sticker on a
650668
// specific message row by id. Used when the persisted DB id is known
651669
// (clear-on-retry via sourceDbIds).

‎src/android/app/src/main/java/com/openminis/app/data/db/MessageVersionDao.kt‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ interface MessageVersionDao {
3737
)
3838
suspend fun versionsForSession(sessionId: String): List<MessageVersionEntity>
3939

40-
@Query("DELETE FROM message_versions WHERE session_id = :sessionId")
41-
suspend fun deleteForSession(sessionId: String): Unit
40+
/**
41+
* Message ids whose every archived version has no live row left — the
42+
* candidates a restore may put back. Counts DISTINCT message ids, not
43+
* version rows: one message can carry several archived versions.
44+
*/
45+
@Query(
46+
"SELECT DISTINCT message_id FROM message_versions WHERE session_id = :sessionId " +
47+
"AND message_id NOT IN (SELECT id FROM messages WHERE session_id = :sessionId)"
48+
)
49+
suspend fun restorableMessageIds(sessionId: String): List<String>
50+
51+
/**
52+
* Retires specific archive rows after they were restored. NEVER delete by
53+
* session: [message_versions] doubles as the pre-edit history of live
54+
* rows, and wiping the session's archive destroyed exactly that history.
55+
*/
56+
@Query("DELETE FROM message_versions WHERE id IN (:ids)")
57+
suspend fun deleteByIds(ids: List<String>)
4258
}

‎src/android/app/src/main/java/com/openminis/app/data/repository/ChatRepository.kt‎

Lines changed: 108 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -850,20 +850,39 @@ class ChatRepository(
850850
)
851851

852852
/**
853-
* [T-msg-version-restore] One-shot recovery for rows lost to the
854-
* 2026-10-14 ordinal-mismatch truncation (deleteMessagesAfter anchored
855-
* dozens of turns early; 1386 rows were archived then deleted from one
856-
* retry tap). Re-inserts every archived version whose source row no
857-
* longer exists in `messages`, at sort orders ABOVE the current tail so
858-
* the live conversation is untouched, and deletes the restored rows from
859-
* `message_versions` so a second call is a no-op.
853+
* How many deleted messages the local archive could still put back
854+
* (messages with archived versions and no live row). Powers the manual
855+
* restore confirmation.
856+
*/
857+
suspend fun restorableArchivedMessageCount(sessionId: String): Int {
858+
val vdao = versionDao ?: return 0
859+
return runCatching { vdao.restorableMessageIds(sessionId).size }.getOrDefault(0)
860+
}
861+
862+
/**
863+
* [T-msg-version-restore] Recovery for rows lost to a truncation whose
864+
* cutoff landed too early (the 2026-10-14 ordinal-mismatch incident:
865+
* 1386 rows archived then deleted from one retry tap). Re-inserts every
866+
* archived version whose source row no longer exists in `messages`.
860867
*
861-
* Only LATEST versions are restored (highest version_index per
862-
* message_id): earlier versions are the pre-edit history the archive
863-
* exists to keep, not rows to resurrect.
868+
* Contract:
869+
* - CHRONOLOGY is rebuilt, not appended: restored rows slot back where
870+
* their created_at places them (see [chronologicalRestoreOrder]), so
871+
* old messages never land after messages the user typed since.
872+
* - Only the RESTORED archive rows are retired afterwards. The session's
873+
* other versions are the pre-edit history of LIVE rows — the reason
874+
* the archive exists — and deleting the whole session archive here
875+
* (the old behaviour) destroyed exactly that history.
876+
* - INVOKED EXPLICITLY by the user ("restore deleted messages"), never
877+
* automatically on session load: a truncation is also how a retry or
878+
* an intentional delete discards content, and silently resurrecting it
879+
* would overrule the user. The confirmation dialog states what the
880+
* archive still holds.
864881
*
865-
* Idempotent: rows whose message_id still exists in `messages` are
866-
* skipped (their live row already carries the content).
882+
* Only LATEST versions are restored (highest version_index per
883+
* message_id): earlier versions are pre-edit history, not rows to
884+
* resurrect. Rows whose message_id still exists live are skipped, so a
885+
* repeat call after the first restore is a no-op.
867886
*/
868887
suspend fun restoreArchivedVersions(sessionId: String): RestoreResult {
869888
val vdao = versionDao ?: return RestoreResult(0, 0, "versionDao unavailable")
@@ -886,33 +905,59 @@ class ChatRepository(
886905
val toRestore = latest.filter { it.messageId !in existingIds }
887906
if (toRestore.isEmpty()) return RestoreResult(0, all.size, null)
888907

889-
// Append above the current tail, preserving source order.
890-
var nextSort = dao.nextSortOrder(sessionId)
891-
val restored = mutableListOf<MessageEntity>()
892-
for (v in toRestore) {
893-
val row = MessageEntity(
894-
id = v.messageId,
895-
sessionId = sessionId,
896-
role = v.role,
897-
partsJson = v.partsJson ?: "[]",
898-
createdAt = v.sourceCreatedAt,
899-
sortOrder = nextSort,
900-
reasoningContent = v.reasoningContent,
901-
errorInfo = v.errorInfo,
902-
modelId = v.modelId,
903-
modelDisplayName = v.modelDisplayName,
904-
bodyRef = v.bodyRef,
905-
bodySha = v.bodySha,
908+
// [T-msg-version-restore] Re-insert by CHRONOLOGY, not at the tail.
909+
// A truncation removes a span from the middle; appending restored rows
910+
// after everything the user typed since reads as old messages arriving
911+
// after new ones. Rebuild the sequence from (created_at, live-first)
912+
// order and renumber in one transaction: two passes over the live rows
913+
// (first to a scratch range, then into the final slot) so the unique
914+
// (session_id, sort_order) index never sees two rows claiming the same
915+
// slot mid-flight.
916+
val restoredById = toRestore.associateBy { it.messageId }
917+
val restoredArchiveIds = toRestore.map { it.id }
918+
val apply: suspend () -> List<MessageEntity> = {
919+
val meta = dao.sortMetaForSession(sessionId)
920+
val order = chronologicalRestoreOrder(
921+
existing = meta.map { SortEntry(it.id, it.createdAt) },
922+
restored = toRestore.map {
923+
SortEntry(it.messageId, it.sourceCreatedAt, restored = true)
924+
},
906925
)
907-
dao.insertMessage(row)
908-
restored.add(row)
909-
nextSort++
910-
}
911-
// Remove the restored entries from the archive so a repeat call is a
912-
// no-op (the live row now carries the content).
913-
runCatching {
914-
vdao.deleteForSession(sessionId)
926+
val scratchBase = order.size + 1_000_000
927+
meta.forEachIndexed { index, row ->
928+
dao.updateSortOrder(row.id, scratchBase + index)
929+
}
930+
val restoredRows = mutableListOf<MessageEntity>()
931+
order.forEachIndexed { index, entry ->
932+
if (entry.restored) {
933+
val v = restoredById.getValue(entry.id)
934+
val row = MessageEntity(
935+
id = v.messageId,
936+
sessionId = sessionId,
937+
role = v.role,
938+
partsJson = v.partsJson ?: "[]",
939+
createdAt = v.sourceCreatedAt,
940+
sortOrder = index,
941+
reasoningContent = v.reasoningContent,
942+
errorInfo = v.errorInfo,
943+
modelId = v.modelId,
944+
modelDisplayName = v.modelDisplayName,
945+
bodyRef = v.bodyRef,
946+
bodySha = v.bodySha,
947+
)
948+
dao.insertMessage(row)
949+
restoredRows.add(row)
950+
} else {
951+
dao.updateSortOrder(entry.id, index)
952+
}
953+
}
954+
// Retire ONLY the restored archive rows — the session's remaining
955+
// versions are live pre-edit history and must survive.
956+
vdao.deleteByIds(restoredArchiveIds)
957+
restoredRows
915958
}
959+
val dbRef = db
960+
val restored = if (dbRef != null) dbRef.withTransaction { apply() } else apply()
916961
AppLogger.info(
917962
TAG,
918963
"[T-msg-version-restore] restored ${restored.size} rows to session " +
@@ -927,6 +972,16 @@ class ChatRepository(
927972
val error: String?,
928973
)
929974

975+
/**
976+
* One slot in a restored transcript. [restored] rows come from the
977+
* archive; the rest are live rows being renumbered around them.
978+
*/
979+
internal data class SortEntry(
980+
val id: String,
981+
val createdAt: Long,
982+
val restored: Boolean = false,
983+
)
984+
930985
/**
931986
* Rewrite a single message row's parts_json in place. Used by
932987
* [com.openminis.app.ui.chat.ChatViewModel.rerunFromToolBlock]'s block-
@@ -1438,6 +1493,22 @@ class ChatRepository(
14381493
companion object {
14391494
private const val TAG = "ChatRepository"
14401495

1496+
/**
1497+
* Merge live rows and restored rows back into one timeline.
1498+
*
1499+
* Order is created_at ascending. A restored row and a live row that
1500+
* share a timestamp keep the LIVE row first: a retry that re-persists
1501+
* the same user turn gets a new id and a later-or-equal created_at,
1502+
* and the restored predecessor must not jump ahead of the turn the
1503+
* user actually kept.
1504+
*/
1505+
internal fun chronologicalRestoreOrder(
1506+
existing: List<SortEntry>,
1507+
restored: List<SortEntry>,
1508+
): List<SortEntry> = (existing + restored).sortedWith(
1509+
compareBy<SortEntry> { it.createdAt }.thenBy { it.restored },
1510+
)
1511+
14411512
/**
14421513
* Upper bound for one authoritative context hydrate. The UI remains
14431514
* tail-windowed, but model context must be able to recover history

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatComposerWidgets.kt‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ import androidx.compose.ui.text.style.TextAlign
6666
import androidx.compose.material3.CircularProgressIndicator
6767
import androidx.compose.ui.unit.sp
6868
import coil.compose.AsyncImage
69+
import coil.request.ImageRequest
70+
import com.openminis.app.ui.DisplayBitmapLimits.limitDisplaySize
6971
import com.openminis.app.data.model.ThinkingLevel
7072
import com.openminis.app.R
7173
import com.openminis.app.ui.theme.ChatColors
@@ -104,7 +106,10 @@ internal fun AttachmentChip(
104106
) {
105107
if (attachment.isImage) {
106108
AsyncImage(
107-
model = attachment.uri,
109+
model = ImageRequest.Builder(LocalContext.current)
110+
.data(attachment.uri)
111+
.limitDisplaySize()
112+
.build(),
108113
contentDescription = attachment.fileName,
109114
modifier = Modifier
110115
.matchParentSize()

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatHistoryPaging.kt‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,20 @@ internal suspend fun ChatViewModel.loadSplitTurnPrefix(beforeSortOrder: Int): Li
8787
// Complete the cut turn before painting the cold-open tail. The probe
8888
// reads only role + sort_order, so a long tool turn does not parse bodies
8989
// merely to find its boundary.
90-
val start = olderTurnStart(beforeSortOrder, turnCount = 1) ?: return emptyList()
90+
//
91+
// The prefix is capped at the same ceiling as the cold-open tail. A tool
92+
// turn can itself be thousands of rows; completing it unbounded would
93+
// undo the render-window cap the moment a page boundary lands inside one.
94+
// What does not fit stays above the window and comes back through
95+
// loadOlderMessages.
96+
val rawStart = olderTurnStart(beforeSortOrder, turnCount = 1) ?: return emptyList()
97+
if (rawStart >= beforeSortOrder) return emptyList()
98+
val capped = chatRepository.dao.loadOlderSortAnchors(
99+
sessionId,
100+
beforeSortOrder,
101+
com.openminis.app.data.repository.ChatRepository.MAX_TAIL_MESSAGES,
102+
).lastOrNull()?.sortOrder ?: return emptyList()
103+
val start = maxOf(rawStart, capped)
91104
if (start >= beforeSortOrder) return emptyList()
92105
return loadSortRange(SortRange(start, beforeSortOrder))
93106
}

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatModels.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ data class ChatMessage(
8383
/** Vendor key for the speaker mark. Empty on ordinary assistant turns. */
8484
val speakerVendor: String? = null,
8585
) {
86+
/**
87+
* [T-delete-anchor-by-id] Whether this row renders as a REAL user bubble:
88+
* text, attachments, or both. A pure-image prompt has empty `content` but
89+
* is a visible user turn — destructive rewinds must anchor to it rather
90+
* than walk past it into the previous turn. Tool-result carriers and
91+
* reminder-only rows render nothing and are filtered out before they ever
92+
* reach the UI list.
93+
*/
94+
val isRenderedUserBubble: Boolean
95+
get() = role == "user" && (
96+
content.isNotBlank() ||
97+
imageUris.isNotEmpty() ||
98+
attachmentUris.isNotEmpty() ||
99+
attachmentNames.isNotEmpty()
100+
)
101+
86102
/**
87103
* [T-bridge-message-ui-leak-android] True when this UI message is the
88104
* internal role-alternation bridge that `injectQueuedPromptsAsNewTurn`

‎src/android/app/src/main/java/com/openminis/app/ui/chat/ChatScreen.kt‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ import androidx.compose.material.icons.filled.MoreVert
140140
import androidx.compose.material.icons.filled.RadioButtonChecked
141141
import androidx.compose.material.icons.filled.RadioButtonUnchecked
142142
import androidx.compose.material.icons.filled.Refresh
143+
import androidx.compose.material.icons.filled.Restore
143144
import androidx.compose.material3.ripple
144145
import androidx.compose.material3.DropdownMenu
145146
import androidx.compose.material3.DropdownMenuItem
@@ -887,6 +888,11 @@ fun ChatScreen(
887888
var showThinkingLevelSheet by remember { mutableStateOf(false) }
888889
var showAttachMenu by remember { mutableStateOf(false) }
889890
var showChatMenu by remember { mutableStateOf(false) }
891+
LaunchedEffect(showChatMenu) {
892+
if (showChatMenu) {
893+
restorableCount = viewModel.restorableArchivedMessageCount()
894+
}
895+
}
890896
var showSkillsSheet by remember { mutableStateOf(false) }
891897
// [T-mcp-integration-android] MCPs-in-Session sheet visibility.
892898
var showMcpsSheet by remember { mutableStateOf(false) }
@@ -897,6 +903,11 @@ fun ChatScreen(
897903
// backing state without needing fragile scope wiring.
898904
var showMoveSheet by remember { mutableStateOf(false) }
899905
var showClearChatDialog by remember { mutableStateOf(false) }
906+
// Archived rows whose live message is gone. 0 hides the restore action;
907+
// recounts whenever the menu opens so a truncation made this session
908+
// shows up without a reload.
909+
var restorableCount by remember { mutableIntStateOf(0) }
910+
var showRestoreDialog by remember { mutableStateOf(false) }
900911
var showCardShareDialog by remember { mutableStateOf(false) }
901912
// [T-android-delete-from-here] Id of the message a pending "Delete From
902913
// Here" would cut at, or null when no confirmation is open. Holding the
@@ -2685,6 +2696,16 @@ fun ChatScreen(
26852696
},
26862697
leadingIcon = { Icon(Icons.Default.Stop, contentDescription = null) },
26872698
)
2699+
if (restorableCount > 0) {
2700+
DropdownMenuItem(
2701+
text = { Text(stringResource(R.string.chat_menu_restore_deleted)) },
2702+
onClick = {
2703+
showChatMenu = false
2704+
showRestoreDialog = true
2705+
},
2706+
leadingIcon = { Icon(Icons.Default.Restore, contentDescription = null) },
2707+
)
2708+
}
26882709
DropdownMenuItem(
26892710
text = { Text(stringResource(R.string.chat_menu_share_card)) },
26902711
onClick = {
@@ -7282,6 +7303,23 @@ fun ChatScreen(
72827303
},
72837304
)
72847305
}
7306+
if (showRestoreDialog) {
7307+
MinisAlertDialog(
7308+
onDismissRequest = { showRestoreDialog = false },
7309+
title = stringResource(R.string.chat_menu_restore_deleted),
7310+
text = pluralStringResource(
7311+
R.plurals.chat_restore_deleted_dialog_body,
7312+
restorableCount,
7313+
restorableCount,
7314+
),
7315+
confirmText = stringResource(R.string.chat_restore_deleted_confirm),
7316+
onConfirm = {
7317+
viewModel.restoreArchivedMessages()
7318+
showRestoreDialog = false
7319+
restorableCount = 0
7320+
},
7321+
)
7322+
}
72857323
if (showClearChatDialog) {
72867324
MinisAlertDialog(
72877325
onDismissRequest = { showClearChatDialog = false },

0 commit comments

Comments
 (0)