* fix(#584): follow stream without freezing (persistent snapshotFlow) The auto-scroll LaunchedEffect was keyed on (messages.size, streamingMessage?.content?.length, isThinking) — those change on EVERY streamed token, so the effect restarted per token and cancelled the in-flight animateScrollToItem, leaving the view stuck/frozen mid-stream. Replace it with a single persistent LaunchedEffect(Unit) + snapshotFlow + collectLatest. The streaming follow now uses an instant scrollToBottom (animated=false) which is idempotent per token and has no animation to cancel; a discrete new message arriving while at the bottom still animates. Because the effect runs once, mirror the params through rememberUpdatedState and read them live inside snapshotFlow/collectLatest so the flow actually fires per token and session-switch detection uses the current session id (rather than stale first-composition closure captures). * fix(#584): leave scroll free during streaming (no auto-follow) m57 follow-up: the previous fix stopped the freeze but still force-pinned to the bottom while streaming. Now, while a message is streaming, the scroll is left completely free — no auto-follow of the streaming message — so the user can scroll up/down to read while it generates. Auto-scroll only on explicit actions (send / FAB / session switch) and when a discrete non-streaming message lands while already pinned to the bottom. streamingMessage is dropped from the snapshotFlow keys so the flow no longer churns per token. --------- Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
e5e4231183
commit
1870e4bb01
1 file changed
+32
-11
@@ -1588,20 +1588,41 @@ private fun ChatLifecycleEffects(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
LaunchedEffect(messages.size, streamingMessage?.content?.length, isThinking) {
|
||||
val totalItems =
|
||||
messages.size +
|
||||
(if (streamingMessage != null) 1 else 0) +
|
||||
(if (isThinking) 1 else 0)
|
||||
if (totalItems > 0) {
|
||||
val isSessionSwitch = currentSessionId != lastSessionId
|
||||
// Auto-scroll to bottom on new messages + session switch (issues #584/#583)
|
||||
//
|
||||
// IMPORTANT (m57, #584 follow-up): while an assistant message is STREAMING
|
||||
// the scroll is left completely FREE — no auto-follow of the streaming
|
||||
// message — so the user can scroll up/down to read while it generates.
|
||||
// Auto-scroll only happens on explicit actions (send / FAB / session
|
||||
// switch, handled elsewhere) and when a discrete non-streaming message
|
||||
// lands while the user is already pinned to the bottom.
|
||||
//
|
||||
// The effect runs once (Unit), so we mirror the params through
|
||||
// rememberUpdatedState and read them live inside the flow/collect — that
|
||||
// keeps the lambda bound to the latest values instead of first-composition
|
||||
// closure captures. streamingMessage is intentionally NOT a flow key, so
|
||||
// the flow doesn't churn per token; we just read it live to gate scrolling.
|
||||
val latestMessages by rememberUpdatedState(messages)
|
||||
val latestStreaming by rememberUpdatedState(streamingMessage)
|
||||
val latestIsThinking by rememberUpdatedState(isThinking)
|
||||
val latestSessionId by rememberUpdatedState(currentSessionId)
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow {
|
||||
Pair(latestMessages.size, latestIsThinking)
|
||||
}.collectLatest { (msgCount, thinking) ->
|
||||
val totalItems = msgCount + (if (thinking) 1 else 0)
|
||||
if (totalItems <= 0) return@collectLatest
|
||||
// While a message is streaming, leave scrolling free (no auto-follow).
|
||||
if (latestStreaming != null) return@collectLatest
|
||||
val isSessionSwitch = latestSessionId != lastSessionId
|
||||
if (isSessionSwitch) {
|
||||
lastSessionId = currentSessionId
|
||||
lastSessionId = latestSessionId
|
||||
listState.scrollToBottom(animated = false)
|
||||
} else if (listState.isAtBottom()) {
|
||||
listState.scrollToBottom(animated = true)
|
||||
return@collectLatest
|
||||
}
|
||||
if (!listState.isAtBottom()) return@collectLatest
|
||||
// Discrete new message while pinned to the bottom — follow it.
|
||||
listState.scrollToBottom(animated = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user