From fff75700ac7a7d27aa379c797f1424afa9227e2b Mon Sep 17 00:00:00 2001 From: weishaupt <1+weishaupt@noreply.localhost> Date: Thu, 29 Oct 2026 18:09:13 +0000 Subject: [PATCH] Fix scrollback snapping back to bottom after scrolling up Wheel-up now clears stickToBottom immediately on the raw scroll event, wheel-down re-checks the resting position, and the auto-scroll effect re-checks stickToBottom right before scrollToItem(0). The scroll-state effect could miss a wheel scroll that began and ended within one frame, so the next incoming line pulled the buffer back to the bottom. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JW57ZvxRmxcDF7AFACLSoV --- src/main/kotlin/ui/ChannelScreen.kt | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt index b146078..e99a0e7 100644 --- a/src/main/kotlin/ui/ChannelScreen.kt +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -818,7 +818,7 @@ fun MessageScrollback( .first { item -> item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq) } - listState.scrollToItem(0) + if (stickToBottom) listState.scrollToItem(0) } } @@ -923,6 +923,31 @@ fun MessageScrollback( .height(snappedHeight) .onGloballyPositioned { containerCoords[0] = it } .pointerHoverIcon(PointerIcon.Text) + // Wheel-up means the user is reading backscroll: stop sticking to the bottom RIGHT NOW, + // on the raw wheel event. The scroll-state effect up top can miss a wheel scroll that starts + // and finishes inside one frame (isScrollInProgress is never seen as true), which left + // stickToBottom set and let the next incoming line yank the buffer back down. + // Wheel-down re-checks the resting position shortly after and re-enables sticking if the + // user is back at the bottom. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Scroll) { + val dy = event.changes.firstOrNull()?.scrollDelta?.y ?: 0f + if (dy < 0f) { + stickToBottom = false + } else if (dy > 0f) { + coroutineScope.launch { + delay(200) + stickToBottom = listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset <= stickToBottomThresholdPx + } + } + } + } + } + } // Remembers the row under a right-click for the menu's "Copy line". Watches the // Initial pass, because ContextMenuArea takes the right-press there, before // the handler below would see it.