chat: natural reading scroll for user and agent messages
- 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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 showSearch 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).
|
||||
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) {
|
||||
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)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
// M6: keep the rail selection in sync with the current channel.
|
||||
|
||||
Reference in new issue
Block a user