desktop: keep text selection on the originally selected message when a new message arrives (#6955)

* desktop: keep text selection on the originally selected message when a new message arrives or is sent

Selection stored positional indices into the merged-items list. When a new
message was appended, the reversed list shifted every index by one — but the
stored start/end indices did not — so the highlight slid onto neighboring
messages.

Anchor the selection to ChatItem IDs instead of list positions. Offsets are
already content-relative (character cursors in rendered text), so they stay
valid across list mutations. Positional indices are derived on read via
derivedStateOf, which keeps per-item reads O(1) amortized. If either
anchored item is removed from the list, a SideEffect synchronously clears
the selection so the copy button does not flash at a stale location.

* desktop: minimize selection fix — anchor ids in SelectionRange

Replaces the previous derivedStateOf-based approach with a surgical
diff: SelectionRange carries startItemId/endItemId alongside the
existing positional indices, and a SideEffect calls resyncIndices()
to translate ids back to current positions when the items list mutates.

All existing call sites of range.startIndex / range.endIndex remain
unchanged. Net diff vs master is +19/-2.

* plans: justify desktop text selection id-anchored fix
This commit is contained in:
Narasimha-sc
2026-05-08 12:55:58 +00:00
committed by GitHub
parent da9b69ca0b
commit e10cfd02e9
2 changed files with 165 additions and 2 deletions
@@ -53,8 +53,10 @@ val LocalItemContext = compositionLocalOf { ItemContext() }
data class SelectionRange(
val startIndex: Int,
val startItemId: Long,
val startOffset: Int,
val endIndex: Int,
val endItemId: Long,
val endOffset: Int
)
@@ -80,11 +82,13 @@ class SelectionManager {
var viewportPosition by mutableStateOf(Offset.Zero)
var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item
var listState: State<LazyListState>? = null
var mergedItemsState: State<MergedItems>? = null
var onCopySelection: (() -> Unit)? = null
private var autoScrollJob: Job? = null
fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) {
range = SelectionRange(startIndex, -1, startIndex, -1)
val id = mergedItemsState?.value?.items?.getOrNull(startIndex)?.newest()?.item?.id ?: return
range = SelectionRange(startIndex, id, -1, startIndex, id, -1)
selectionState = SelectionState.Selecting
anchorWindowY = anchorY
anchorWindowX = anchorX
@@ -97,7 +101,8 @@ class SelectionManager {
fun updateFocusIndex(index: Int) {
val r = range ?: return
range = r.copy(endIndex = index)
val id = mergedItemsState?.value?.items?.getOrNull(index)?.newest()?.item?.id ?: return
range = r.copy(endIndex = index, endItemId = id)
}
fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) {
@@ -176,6 +181,15 @@ class SelectionManager {
updateFocusIndex(idx)
}
fun resyncIndices() {
val r = range ?: return
val items = mergedItemsState?.value?.items ?: return
val newStartIndex = items.indexOfFirst { it.newest().item.id == r.startItemId }
val newEndIndex = items.indexOfFirst { it.newest().item.id == r.endItemId }
if (newStartIndex < 0 || newEndIndex < 0) clearSelection()
else range = r.copy(startIndex = newStartIndex, endIndex = newEndIndex)
}
fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) {
val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop
if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) {
@@ -320,11 +334,15 @@ fun BoxScope.SelectionHandler(
}
manager.listState = listState
manager.mergedItemsState = mergedItems
manager.onCopySelection = {
clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, revealedItems.value, linkMode)))
showToast(generalGetString(MR.strings.copied))
}
// Resync after the items list mutates (new message arrives, item deleted).
SideEffect { manager.resyncIndices() }
return Modifier
.focusRequester(focusRequester)
.focusable()