chat: natural reading scroll for user and agent messages
CI / Gateway plugin tests (push) Successful in 4m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m14s

- 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).
This commit is contained in:
ARIA committed 2026-08-22 20:55:35 +02:00
1 parent 8fba00ba7f
commit 4866f14231
1 file changed
+65 -5
@@ -210,6 +210,28 @@ fun ChatScreen(controller: IrisController) {
if (animate) listState.animateScrollToItem(last, offset) else listState.scrollToItem(last, offset) 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<String?>(null) }
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) }
@@ -261,20 +283,58 @@ fun ChatScreen(controller: IrisController) {
// (Re)entering a lane always lands on the latest message (bottom-aligned). // (Re)entering a lane always lands on the latest message (bottom-aligned).
LaunchedEffect(currentLane) { scrollToBottom(animate = false) } LaunchedEffect(currentLane) { scrollToBottom(animate = false) }
// Follow the bottom only while the user is already there; if they've // A new row appeared (user send, agent reply, tool card) or the typing
// scrolled up to read history, new messages must not yank the view back // indicator came/went away.
// (the back-to-bottom button gets them back). // - 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) { LaunchedEffect(items.size, typing) {
if (!isAtBottom) return@LaunchedEffect
val last = items.size - 1 val last = items.size - 1
if (last >= 0) { 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 isAutoScrolling = true
try { try {
if (isUserMessage) {
scrollToBottom(animate = true) scrollToBottom(animate = true)
} 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 { } finally {
isAutoScrolling = false 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
}
} }
// M6: keep the rail selection in sync with the current channel. // M6: keep the rail selection in sync with the current channel.