From e5e4231183d71c1af00c1f1b738cbf7829a8a3e7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:13:30 +0300 Subject: [PATCH] fix(#583): scroll-to-bottom aligns last message bottom to viewport (#590) * fix(#583): align last message bottom to viewport on scroll-to-bottom animateScrollToItem(lastIndex) only top-aligns the last item, so when the last message is taller than the viewport its bottom is clipped below the fold. Replace the 4 scroll-to-bottom call sites (send auto-scroll, session-switch, auto-scroll-on-new-message, and the FAB) with a scrollToBottom() helper that top-aligns then scrolls the exact remaining delta so the last item's bottom edge is visible. Using the remaining delta (not scrollOffset=Int.MAX_VALUE) avoids integer overflow in the internal scroll-position clamp, which would wrap the offset to 0 and scroll back to the top for tall items. * fix(#583): keep smooth animation on scroll-to-bottom when animated agy review (PR #590) flagged that the animated path did an instant scrollToItem then only animated the small remaining delta, causing a hard jump on FAB / send clicks instead of a smooth scroll. Use animateScrollToItem when animated, scrollToItem when instant. --------- Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index aed5c013..618f8351 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -479,7 +480,7 @@ fun ChatScreen( (if (streamingState.streamingMessage != null) 1 else 0) + (if (streamingState.isThinking) 1 else 0) if (totalItems > 0) { - listState.animateScrollToItem(totalItems - 1) + listState.scrollToBottom(animated = true) } } }, @@ -1469,6 +1470,42 @@ private fun LazyListState.isAtBottom(threshold: Int = 3): Boolean { return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold } +/** + * Scroll so the bottom edge of the last item is aligned to the bottom of the + * viewport (issue #583). + * + * `animateScrollToItem(lastIndex)` only top-aligns the last item, so when the + * last item is taller than the viewport its bottom is clipped below the fold. + * We first top-align (instant), then scroll the exact remaining gap so the + * bottom is visible. Using the remaining delta (not `scrollOffset = Int.MAX_VALUE`) + * avoids integer overflow in the internal scroll-position clamp, which would + * otherwise wrap the offset to 0 and scroll back to the top. + */ +private suspend fun LazyListState.scrollToBottom(animated: Boolean) { + val layoutInfo = this.layoutInfo + if (layoutInfo.totalItemsCount == 0) return + val lastIndex = layoutInfo.totalItemsCount - 1 + // Top-align the last item first so layoutInfo reflects the last item's offset. + // When animated, use the animated variant so the FAB / send clicks keep a + // smooth scroll instead of an instant jump followed by a tiny animation. + if (animated) { + animateScrollToItem(lastIndex) + } else { + scrollToItem(lastIndex) + } + val info = this.layoutInfo + val lastItem = info.visibleItemsInfo.lastOrNull { it.index == lastIndex } ?: return + val remaining = + (lastItem.offset + lastItem.size + info.afterContentPadding) - info.viewportEndOffset + if (remaining > 0) { + if (animated) { + animateScrollBy(remaining.toFloat()) + } else { + scroll { scrollBy(remaining.toFloat()) } + } + } +} + @Composable private fun ChatLifecycleEffects( sessionId: String?, @@ -1561,9 +1598,9 @@ private fun ChatLifecycleEffects( val isSessionSwitch = currentSessionId != lastSessionId if (isSessionSwitch) { lastSessionId = currentSessionId - listState.scrollToItem(totalItems - 1) + listState.scrollToBottom(animated = false) } else if (listState.isAtBottom()) { - listState.animateScrollToItem(totalItems - 1) + listState.scrollToBottom(animated = true) } } } @@ -1957,7 +1994,7 @@ private fun BoxScope.ChatScrollToBottomFab( (if (streamingMessage != null) 1 else 0) + (if (isThinking) 1 else 0) if (totalItems > 0) { - listState.animateScrollToItem(totalItems - 1) + listState.scrollToBottom(animated = true) } } },