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@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-13 23:13:30 +03:00
1 parent c446715608
commit e5e4231183
1 file changed
+41 -4
@@ -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)
}
}
},