From 1870e4bb01e6522d816121e0a8b697e6e325d876 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:04:17 +0300 Subject: [PATCH] fix(#584): keep scroll free during streaming (no auto-follow) (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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/hermescontrol/ui/chat/ChatScreen.kt | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 618f8351..42dac059 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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) } }