@@ -4532,13 +4532,29 @@ class ChatViewModel(
45324532 try {
45334533 val sid = realSessionId.takeIf { it.isNotEmpty() } ? : sessionId
45344534
4535- // Find the DB sort_order cutoff for this user message.
4536- // UI visible user messages are the N-th user msg with actual text content.
4537- // Count which visible user message this is (0-based).
4538- val visibleUserIndex = messages.subList(0 , index + 1 ).count { it.role == " user" } - 1
4539- val cutoffSortOrder = visibleUserCutoff(sid, visibleUserIndex)?.let { it.sortOrder + 1 } ? : - 1
4540- if (cutoffSortOrder >= 0 ) {
4541- chatRepository.deleteMessagesAfter(sid, cutoffSortOrder)
4535+ // [T-delete-anchor-by-id] Find the DB sort_order cutoff for this
4536+ // user message DIRECTLY by row id. The old ordinal walk
4537+ // (visibleUserIndex → visibleUserCutoff) counted a different set
4538+ // of user rows than the UI list — UI counts every user-role
4539+ // ChatMessage, the DB walk skips toolResult-only user rows — so
4540+ // the anchor could land dozens of turns early and
4541+ // deleteMessagesAfter wiped the whole session tail
4542+ // (2026-10-14 incident: 1386 rows lost from one retry tap).
4543+ // ChatMessage.id is the DB row id (toChatMessages maps
4544+ // entity.id 1:1), so this lookup is exact. sourceDbIds covers
4545+ // the merged-assistant case; a user message is never merged.
4546+ val anchorIds = listOf (message.id) + message.sourceDbIds
4547+ val anchorSortOrder = anchorIds.firstNotNullOfOrNull { anchorId ->
4548+ runCatching { chatRepository.dao.sortOrderForMessageId(anchorId) }.getOrNull()
4549+ }
4550+ if (anchorSortOrder != null ) {
4551+ chatRepository.deleteMessagesAfter(sid, anchorSortOrder + 1 )
4552+ } else {
4553+ AppLogger .warning(
4554+ TAG ,
4555+ " retryFromMessage: anchor row ${message.id.take(8 )} not found in DB — " +
4556+ " skipping DB truncation (UI already truncated)" ,
4557+ )
45424558 }
45434559
45444560 awaitBoundedHistoryRebuild(sid)
@@ -4558,9 +4574,15 @@ class ChatViewModel(
45584574 * [T-android-delete-from-here] Resolve the DB `sort_order` to cut at so
45594575 * that [index] and everything after it is removed.
45604576 *
4561- * Anchoring is by visible-user-message ordinal rather than by row id
4562- * because the UI list and the persisted rows are not 1:1 — see
4563- * [deleteFromMessage]'s note on synthetic `<system-reminder>` rows.
4577+ * [T-delete-anchor-by-id] Anchoring is by the tapped message's DB row id,
4578+ * NOT by visible-user ordinal. The old ordinal walk (visibleUserIndex →
4579+ * visibleUserCutoff) counted a different set of user rows than the UI
4580+ * list — the UI counts every user-role ChatMessage while the DB walk
4581+ * skips toolResult-only user rows — so the anchor could land dozens of
4582+ * turns early and deleteMessagesAfter silently wiped the session tail
4583+ * (2026-10-14 incident: 1386 rows archived from one delete tap).
4584+ * ChatMessage.id is the DB row id, and sourceDbIds covers merged
4585+ * assistant bubbles, so the lookup is exact.
45644586 *
45654587 * When the target is an ASSISTANT message the cut is anchored to the user
45664588 * turn it belongs to: we find the last visible user message at or before
@@ -4574,10 +4596,22 @@ class ChatViewModel(
45744596 index : Int ,
45754597 target : ChatMessage ,
45764598 ): Int {
4577- val visibleUserIndex = messages.subList(0 , index + 1 ).count { it.role == " user" } - 1
4578- if (visibleUserIndex < 0 ) return 0
4579- val anchor = visibleUserCutoff(sid, visibleUserIndex) ? : return - 1
4580- return if (target.role == " user" ) anchor.sortOrder else anchor.sortOrder + 1
4599+ // [T-delete-anchor-by-id] Resolve the anchor row id first: the target
4600+ // itself for a user message, the preceding visible user message for
4601+ // an assistant target (its whole turn goes).
4602+ val anchorMessage: ChatMessage ? = if (target.role == " user" ) {
4603+ target
4604+ } else {
4605+ (index downTo 0 ).asSequence()
4606+ .map { messages[it] }
4607+ .firstOrNull { it.role == " user" && it.content.isNotBlank() }
4608+ }
4609+ val anchor = anchorMessage ? : return - 1
4610+ val anchorIds = listOf (anchor.id) + anchor.sourceDbIds
4611+ val anchorSortOrder = anchorIds.firstNotNullOfOrNull { anchorId ->
4612+ runCatching { chatRepository.dao.sortOrderForMessageId(anchorId) }.getOrNull()
4613+ } ? : return - 1
4614+ return if (target.role == " user" ) anchorSortOrder else anchorSortOrder + 1
45814615 }
45824616
45834617
@@ -5862,39 +5896,11 @@ class ChatViewModel(
58625896 }
58635897
58645898
5865- internal suspend fun visibleUserCutoff (
5866- sid : String ,
5867- visibleUserIndex : Int ,
5868- ): com.openminis.app.data.db.MessageAnchorRow ? {
5869- if (visibleUserIndex < 0 ) return null
5870- val total = chatRepository.dao.messageCountForSession(sid)
5871- var offset = 0
5872- var seen = 0
5873- while (offset < total) {
5874- val page = chatRepository.dao.loadMessageAnchorsPage(sid, offset, 100 , 4_096 )
5875- if (page.isEmpty()) break
5876- for (row in page) {
5877- if (row.role != " user" || ! anchorHeadHasVisibleUserText(row.headText.orEmpty())) continue
5878- if (seen == visibleUserIndex) return row
5879- seen++
5880- }
5881- offset + = page.size
5882- }
5883- return null
5884- }
5885-
5886- private fun anchorHeadHasVisibleUserText (head : String ): Boolean = try {
5887- val arr = org.json.JSONArray (head)
5888- (0 until arr.length()).any { i ->
5889- val o = arr.optJSONObject(i) ? : return @any false
5890- val value = o.optString(" value" , " " )
5891- o.optString(" type" ) == " text" &&
5892- stripAttachedFilesXml(value).isNotBlank() &&
5893- ! value.trimStart().startsWith(" <system-reminder>" )
5894- }
5895- } catch (_: Exception ) {
5896- true
5897- }
5899+ // [T-delete-anchor-by-id] visibleUserCutoff / anchorHeadHasVisibleUserText
5900+ // were removed: the ordinal walk counted a different set of user rows
5901+ // than the UI list and caused the 2026-10-14 session-loss incident.
5902+ // Delete/retry/edit now anchor by DB row id via
5903+ // ChatDao.sortOrderForMessageId — see resolveDeleteCutoffSortOrder.
58985904
58995905
59005906 /* *
0 commit comments