@@ -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
0 commit comments