fix(#584): keep scroll free during streaming (no auto-follow) (#591)

* 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:
M57andM57 authored and GitHub committed 2026-07-14 00:04:17 +03:00
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)
}
}