From 4866f14231859d5488ab8ae5741b04266bc6d7c8 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sat, 22 Aug 2026 20:55:35 +0200 Subject: [PATCH] chat: natural reading scroll for user and agent messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User send: always scroll so the bottom of the own message is visible above the composer (scroll = new content: message + spacers), even while reading history. - Agent message: while at the bottom, land on the natural reading position — top-aligned with the viewport top when the bubble is taller than the viewport, bottom-aligned otherwise. - Streaming follow: keep the live bubble's bottom visible while it fits the viewport; once it outgrows the viewport, top-align it once and hand over to the user (no yank-back on later deltas). --- .../kotlin/iris/ui/screens/ChatScreen.kt | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 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 22c7a46..8d198c1 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -210,6 +210,28 @@ fun ChatScreen(controller: IrisController) { if (animate) listState.animateScrollToItem(last, offset) else listState.scrollToItem(last, offset) } } + + // Natural reading position for the newest row: bottom-aligned like + // scrollToBottom, UNLESS the row is taller than the viewport — then its + // TOP is aligned with the viewport top, so a long agent message can be + // read from the start (the rest is reached by scrolling down). + // Returns true when the row was top-aligned. + suspend fun scrollToNewest(animate: Boolean): Boolean { + scrollToBottom(animate) + val info = listState.layoutInfo + val last = info.totalItemsCount - 1 + val item = info.visibleItemsInfo.firstOrNull { it.index == last } ?: return false + if (item.size > info.viewportSize.height) { + if (animate) listState.animateScrollToItem(last) else listState.scrollToItem(last) + return true + } + return false + } + // A streaming bubble that outgrew the viewport was top-aligned once and + // handed over to the user (read from the start, scroll down freely). + // Remember its id so later deltas don't yank the view back to the top + // when the user scrolls down to the bottom. + var topAlignedBubbleId by remember { mutableStateOf(null) } var input by remember { mutableStateOf("") } var showSearch by remember { mutableStateOf(false) } var showNewChannel by remember { mutableStateOf(false) } @@ -261,19 +283,57 @@ fun ChatScreen(controller: IrisController) { // (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 - // (the back-to-bottom button gets them back). + // A new row appeared (user send, agent reply, tool card) or the typing + // indicator came/went away. + // - The user's own message ALWAYS brings its bottom into view above the + // composer, even while the user is reading history: the scroll is + // exactly the new content (message + spacers), so the bottom of the + // bubble and the input stay visible no matter how long the message is. + // - Agent content only follows while the user is at the bottom (the + // back-to-bottom button gets them back), and lands on the natural + // reading position: top-aligned when the bubble is taller than the + // viewport, bottom-aligned otherwise. LaunchedEffect(items.size, typing) { - if (!isAtBottom) return@LaunchedEffect val last = items.size - 1 - if (last >= 0) { - isAutoScrolling = true - try { + if (last < 0) return@LaunchedEffect + val lastItem = items[last] as? MessageItem + val isUserMessage = lastItem != null && lastItem.role == ROLE_USER && !lastItem.isSystem + if (!isUserMessage && !isAtBottom) return@LaunchedEffect + isAutoScrolling = true + try { + if (isUserMessage) { scrollToBottom(animate = true) - } finally { - isAutoScrolling = false + } else { + // Instant while a live bubble is opening: it grows right away + // and the streaming follow below takes over. + scrollToNewest(animate = lastItem?.streaming != true) } + } finally { + isAutoScrolling = false + } + } + + // Follow a live streaming bubble while the user is at the bottom: keep + // its bottom visible while it fits the viewport; once it outgrows the + // viewport, top-align it and hand over to the user (topAlignedBubbleId + // stops the follow for that bubble, so the user can read from the start + // and scroll down freely without being yanked back to the top). + val lastStreaming = items.lastOrNull() as? MessageItem + val streamingKey = + if (lastStreaming != null && lastStreaming.streaming && lastStreaming.role == ROLE_ASSISTANT) { + lastStreaming.id to lastStreaming.text.length + } else { + null + } + LaunchedEffect(streamingKey) { + val key = streamingKey ?: return@LaunchedEffect + val bubbleId = key.first + if (topAlignedBubbleId == bubbleId || !isAtBottom) return@LaunchedEffect + isAutoScrolling = true + try { + if (scrollToNewest(animate = false)) topAlignedBubbleId = bubbleId + } finally { + isAutoScrolling = false } }