Chat: scroll to bottom of newest message on open and on new messages
CI / Gateway plugin tests (push) Successful in 4m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m37s

scrollToItem(last) top-aligns the last row, so a final message taller
than the viewport stayed cut off at the bottom. New scrollToBottom()
brings the last row into view, then aligns its bottom edge with the
viewport bottom. The isAtBottom check now also treats 'can't scroll
forward' as at-bottom, so auto-scroll and the back-to-bottom button
behave correctly when the newest message is taller than the viewport.
This commit is contained in:
ARIA committed 2026-08-22 11:37:30 +02:00
1 parent 6591d7cec0
commit dd43033888
1 file changed
+31 -15
@@ -167,10 +167,14 @@ fun ChatScreen(controller: IrisController) {
val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
val listState = rememberLazyListState()
// "At the bottom" while the last row's bottom edge is within a small
// tolerance of its rest position (16dp above the viewport bottom, from the
// list's content padding). Scrolling up by more than that reveals the
// back-to-bottom button and stops new messages from auto-scrolling the view.
// "At the bottom" when the list can't scroll further forward, or the last
// row's bottom edge is within a small tolerance of the viewport bottom
// (the tolerance avoids flicker when the newest message is shorter than
// the viewport). The canScrollForward check is what makes this work when
// the newest message is TALLER than the viewport: the gap is then
// negative even at the true bottom. Scrolling up by more than the
// tolerance reveals the back-to-bottom button and stops new messages from
// auto-scrolling the view.
val bottomThresholdPx = 8.dp.value * LocalDensity.current.density
var isAtBottom by remember { mutableStateOf(true) }
var isAutoScrolling by remember { mutableStateOf(false) }
@@ -182,10 +186,28 @@ fun ChatScreen(controller: IrisController) {
true
} else {
val gap = (info.viewportEndOffset - (last.offset + last.size)).toFloat()
last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx
!listState.canScrollForward ||
(last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx)
}
}.collect { isAtBottom = it }
}
// Scroll so the newest row's BOTTOM edge is visible: first bring the last
// row into view (top-aligned), then, if it is taller than the viewport,
// align its bottom with the viewport bottom. A plain scrollToItem(last)
// would leave a long final message cut off at the bottom.
suspend fun scrollToBottom(animate: Boolean) {
val total = listState.layoutInfo.totalItemsCount
if (total == 0) return
val last = total - 1
if (animate) listState.animateScrollToItem(last) else listState.scrollToItem(last)
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == last } ?: return
val offset = (item.offset + item.size - info.viewportSize.height).coerceAtLeast(0)
if (offset > 0) {
if (animate) listState.animateScrollToItem(last, offset) else listState.scrollToItem(last, offset)
}
}
var input by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
var showNewChannel by remember { mutableStateOf(false) }
@@ -235,11 +257,8 @@ fun ChatScreen(controller: IrisController) {
focusManager.clearFocus(force = true)
}
// (Re)entering a lane always lands on the latest message.
LaunchedEffect(currentLane) {
val last = items.size - 1
if (last >= 0) listState.scrollToItem(last)
}
// (Re)entering a lane always lands on the latest message (bottom-aligned).
LaunchedEffect(currentLane) { scrollToBottom(animate = false) }
// Follow the bottom only while the user is already there; if they've
// scrolled up to read history, new messages must not yank the view back
@@ -250,7 +269,7 @@ fun ChatScreen(controller: IrisController) {
if (last >= 0) {
isAutoScrolling = true
try {
listState.animateScrollToItem(last)
scrollToBottom(animate = true)
} finally {
isAutoScrolling = false
}
@@ -551,10 +570,7 @@ fun ChatScreen(controller: IrisController) {
.background(IrisColors.surface)
.border(1.dp, IrisColors.divider, CircleShape)
.clickable {
val last = listState.layoutInfo.totalItemsCount - 1
if (last >= 0) {
drawerScope.launch { listState.animateScrollToItem(last) }
}
drawerScope.launch { scrollToBottom(animate = true) }
},
contentAlignment = Alignment.Center,
) {