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)
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user