From dd430338881b2cc48c9c7b17f71fa1e2e9b0e5e1 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sat, 22 Aug 2026 11:37:30 +0200 Subject: [PATCH] Chat: scroll to bottom of newest message on open and on new messages 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. --- .../kotlin/iris/ui/screens/ChatScreen.kt | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index e5c9a8c..f4fd4b5 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -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, ) {