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 threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
val listState = rememberLazyListState() val listState = rememberLazyListState()
// "At the bottom" while the last row's bottom edge is within a small // "At the bottom" when the list can't scroll further forward, or the last
// tolerance of its rest position (16dp above the viewport bottom, from the // row's bottom edge is within a small tolerance of the viewport bottom
// list's content padding). Scrolling up by more than that reveals the // (the tolerance avoids flicker when the newest message is shorter than
// back-to-bottom button and stops new messages from auto-scrolling the view. // 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 val bottomThresholdPx = 8.dp.value * LocalDensity.current.density
var isAtBottom by remember { mutableStateOf(true) } var isAtBottom by remember { mutableStateOf(true) }
var isAutoScrolling by remember { mutableStateOf(false) } var isAutoScrolling by remember { mutableStateOf(false) }
@@ -182,10 +186,28 @@ fun ChatScreen(controller: IrisController) {
true true
} else { } else {
val gap = (info.viewportEndOffset - (last.offset + last.size)).toFloat() 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 } }.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 input by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var showNewChannel by remember { mutableStateOf(false) } var showNewChannel by remember { mutableStateOf(false) }
@@ -235,11 +257,8 @@ fun ChatScreen(controller: IrisController) {
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
} }
// (Re)entering a lane always lands on the latest message. // (Re)entering a lane always lands on the latest message (bottom-aligned).
LaunchedEffect(currentLane) { LaunchedEffect(currentLane) { scrollToBottom(animate = false) }
val last = items.size - 1
if (last >= 0) listState.scrollToItem(last)
}
// Follow the bottom only while the user is already there; if they've // 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 // scrolled up to read history, new messages must not yank the view back
@@ -250,7 +269,7 @@ fun ChatScreen(controller: IrisController) {
if (last >= 0) { if (last >= 0) {
isAutoScrolling = true isAutoScrolling = true
try { try {
listState.animateScrollToItem(last) scrollToBottom(animate = true)
} finally { } finally {
isAutoScrolling = false isAutoScrolling = false
} }
@@ -551,10 +570,7 @@ fun ChatScreen(controller: IrisController) {
.background(IrisColors.surface) .background(IrisColors.surface)
.border(1.dp, IrisColors.divider, CircleShape) .border(1.dp, IrisColors.divider, CircleShape)
.clickable { .clickable {
val last = listState.layoutInfo.totalItemsCount - 1 drawerScope.launch { scrollToBottom(animate = true) }
if (last >= 0) {
drawerScope.launch { listState.animateScrollToItem(last) }
}
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {