From cc8f8a27734cc243f53abf2f62a789ff28b7bb21 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:26:49 +0300 Subject: [PATCH] fix(#682): centralized chat scroll controller with bottom-follow + unread (#683) Introduce ChatScrollController owning all chat scroll behavior: - continuous bottom-follow intent tracked from LazyListState (not only on data change) - pixel-precise bottom detection (last item edge vs viewport, small tolerance) - streaming follow that pauses on upward scroll and never forces the reader down - tail updates keyed on messages/streaming/thinking/subagent/clarify - unread (new-message) count shown on the scroll-to-bottom FAB, cleared on tap - paging anchor preserved by message id across older-history prepend - all scroll commands (send/FAB/session switch/search/paging) serialized via one scope Closes #682 Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 82 +++++-- .../chat/components/ChatLifecycleEffects.kt | 70 ++---- .../chat/components/ChatScrollController.kt | 228 ++++++++++++++++++ .../ui/chat/components/ChatScrollFab.kt | 110 +++------ 4 files changed, 335 insertions(+), 155 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt 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 db7f10dd..3a38cf48 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 @@ -81,7 +81,8 @@ import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.SearchBarRow -import com.m57.hermescontrol.ui.chat.components.scrollToBottom +import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController +import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.HermesScaffold @@ -121,6 +122,8 @@ fun ChatScreen( val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val listState = rememberLazyListState() + val scrollScope = rememberCoroutineScope() + val scrollController = rememberChatScrollController(listState, scrollScope) var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) } // Periodic session sync while connected. @@ -132,6 +135,10 @@ fun ChatScreen( } // Arm + trigger older-message paging when the user scrolls near the top. + // Before prepending, capture the anchor (first visible message id + offset) + // so the same content stays under the reader's eye after the insert + // (issue #682). Restored in a LaunchedEffect once the page actually lands. + val pagingAnchor = remember { mutableStateOf?>(null) } LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) { if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect snapshotFlow { listState.firstVisibleItemIndex } @@ -140,16 +147,59 @@ fun ChatScreen( if (firstVisibleIndex > 2) { isOlderPagingArmed = true } else if (isOlderPagingArmed) { + val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id + val offset = scrollController.captureAnchorOffset() + pagingAnchor.value = if (anchorId != null) anchorId to offset else null viewModel.loadOlderMessages() } } } + + // After older history is prepended, restore the anchor message (by id, so + // streaming-token inserts during paging don't skew the index) plus offset. + LaunchedEffect(state.messages) { + val anchor = pagingAnchor.value ?: return@LaunchedEffect + val (anchorId, offset) = anchor + val index = state.messages.indexOfFirst { it.id == anchorId } + if (index >= 0) { + scrollController.scrollToItem(index, offset) + pagingAnchor.value = null + } + } + + // Continuous bottom-follow tracking from LazyListState (issue #682). + LaunchedEffect(Unit) { + scrollController.observeUserScrollPosition() + } + + // Drive follow + unseen tracking from a stable tail-content key covering + // messages, streaming, thinking, subagent cards, and clarify prompts + // (issue #682). Replaces the old item-count heuristic that ignored the + // streaming tail. + LaunchedEffect( + state.messages, + streamingState.streamingMessage, + streamingState.isThinking, + state.subagentIndicators, + state.clarifyRequest, + ) { + scrollController.onTailChanged( + tailKey = + tailContentKey( + messages = state.messages, + streamingMessage = streamingState.streamingMessage, + isThinking = streamingState.isThinking, + subagentIndicators = state.subagentIndicators, + clarifyRequest = state.clarifyRequest, + ), + messageCount = state.messages.size, + ) + } val showScrollToBottom by remember { derivedStateOf { - state.messages.isNotEmpty() && listState.canScrollForward + scrollController.showFab(state.messages.isNotEmpty()) } } - val scrollScope = rememberCoroutineScope() var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } @@ -277,8 +327,6 @@ fun ChatScreen( connectionStatus = state.connectionStatus, currentSessionId = state.currentSessionId, messages = state.messages, - streamingMessage = streamingState.streamingMessage, - isThinking = streamingState.isThinking, errorMessage = state.errorMessage, backgroundCompleteMessage = state.backgroundCompleteMessage, isSearchActive = state.isSearchActive, @@ -288,6 +336,7 @@ fun ChatScreen( sudoPrompt = state.sudoPrompt, secretPrompt = state.secretPrompt, listState = listState, + scrollController = scrollController, snackbarHostState = snackbarHostState, viewModel = viewModel, ) @@ -446,14 +495,12 @@ fun ChatScreen( // Loading overlay ChatLoadingOverlay(isLoading = state.isLoading) - // Scroll-to-bottom FAB + // Scroll-to-bottom FAB (issue #682): shows while follow is + // paused and renders the unseen-message badge. ChatScrollToBottomFab( - showScrollToBottom = showScrollToBottom, - scrollScope = scrollScope, - listState = listState, - messages = state.messages, - streamingMessage = streamingState.streamingMessage, - isThinking = streamingState.isThinking, + show = showScrollToBottom, + pendingCount = scrollController.pendingCount, + onScrollToBottom = scrollController::resumeFollowing, ) // Reaction heartsanimation (purely cosmetic — fades out @@ -471,15 +518,8 @@ fun ChatScreen( onSend = { viewModel.sendMessage(inputFieldValue.text) inputFieldValue = TextFieldValue("") - scrollScope.launch { - val totalItems = - state.messages.size + - (if (streamingState.streamingMessage != null) 1 else 0) + - (if (streamingState.isThinking) 1 else 0) - if (totalItems > 0) { - listState.scrollToBottom(animated = true) - } - } + // Jump to bottom after send (serialized through the controller). + scrollController.jumpToBottom(animated = true) }, onMicTap = { if (isListening) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index f78c14fd..7ac05e48 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -11,12 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -30,7 +24,6 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.SecretPromptUi import com.m57.hermescontrol.ui.chat.SudoPromptUi -import kotlinx.coroutines.flow.collectLatest @Composable fun ChatLifecycleEffects( @@ -38,8 +31,6 @@ fun ChatLifecycleEffects( connectionStatus: ConnectionStatus, currentSessionId: String?, messages: List, - streamingMessage: ChatMessage?, - isThinking: Boolean, errorMessage: String?, backgroundCompleteMessage: String?, isSearchActive: Boolean, @@ -49,6 +40,7 @@ fun ChatLifecycleEffects( sudoPrompt: SudoPromptUi?, secretPrompt: SecretPromptUi?, listState: LazyListState, + scrollController: ChatScrollController, snackbarHostState: SnackbarHostState, viewModel: ChatViewModel, ) { @@ -61,19 +53,24 @@ fun ChatLifecycleEffects( } // Switch to session from notification/history - var lastSessionId by remember { mutableStateOf(null) } - val pendingSessionId = NavigationController.pendingSessionId - LaunchedEffect(sessionId, pendingSessionId, connectionStatus) { + LaunchedEffect(sessionId, NavigationController.pendingSessionId, connectionStatus) { if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect - val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId + val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId if (!target.isNullOrBlank()) { viewModel.switchSession(target) - if (target == pendingSessionId) { + if (target == NavigationController.pendingSessionId) { NavigationController.pendingSessionId = null } } } + // Land instantly at the bottom on a session switch (issue #682). + LaunchedEffect(currentSessionId) { + if (currentSessionId != null) { + scrollController.jumpToBottom(animated = false) + } + } + // Lifecycle observer for notification foreground service DisposableEffect(lifecycleOwner) { val observer = @@ -114,44 +111,6 @@ fun ChatLifecycleEffects( } } - // 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 = latestSessionId - listState.scrollToBottom(animated = false) - return@collectLatest - } - if (!listState.isAtBottom()) return@collectLatest - // Discrete new message while pinned to the bottom — follow it. - listState.scrollToBottom(animated = true) - } - } - // Show error as snackbar LaunchedEffect(errorMessage) { errorMessage?.let { error -> @@ -183,7 +142,8 @@ fun ChatLifecycleEffects( ) } - // Scroll to current search match + // Scroll to current search match (serialized through the controller so it + // doesn't compete with auto-follow / FAB / send scrolls). LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) { if (isSearchActive && currentSearchMatchIndex >= 0 && @@ -191,9 +151,7 @@ fun ChatLifecycleEffects( messages.isNotEmpty() ) { val targetIndex = searchMatchIndices[currentSearchMatchIndex] - listState.animateScrollToItem( - targetIndex.coerceIn(0, messages.lastIndex), - ) + scrollController.scrollToSearchMatch(targetIndex.coerceIn(0, messages.lastIndex)) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt new file mode 100644 index 00000000..0e5ec72a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -0,0 +1,228 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +/** + * Owns all chat scroll behavior (issue #682): + * + * 1. **Bottom-follow intent** ([isFollowingBottom]) is tracked continuously from + * [LazyListState] via [snapshotFlow], not decided only when new data arrives. + * 2. **Precise bottom detection** ([isAtBottom]) checks the last item's bottom + * edge against the viewport bottom within a small pixel tolerance, instead of + * an item-count threshold. + * 3. **Streaming follow** — [onTailChanged] keeps following streamed output only + * while the reader is pinned at the bottom. An upward scroll pauses follow + * immediately (via [isFollowingBottom]) and never forces the reader back down. + * 4. **Tail updates** — callers funnel every tail change (messages, streaming, + * thinking, subagent cards, clarify prompts) through [onTailChanged] with a + * stable tail key, so follow + unread tracking react to real content changes. + * 5. **Unread indicator** — when follow is paused, [pendingCount] accumulates the + * number of tail updates; shows on the FAB. Tapping the FAB resumes following + * and clears the count. + * 6. **Paging anchor preservation** — capture the first visible item + offset + * with [captureAnchor] before prepending older history, then + * [restoreAnchor] after insertion so the same content stays under the eye. + * 7. **Serialized scroll commands** — every scroll (send, FAB, session switch, + * search navigation, auto-follow) is launched from [scope] so animations + * don't compete. + * + * Construct with [rememberChatScrollController]. + */ +class ChatScrollController( + private val listState: LazyListState, + internal val scope: CoroutineScope, +) { + /** True while the reader is pinned at (or within tolerance of) the bottom. */ + var isFollowingBottom by mutableStateOf(true) + private set + + /** + * Number of new messages that arrived while follow was paused. Shown on the + * scroll-to-bottom FAB; cleared when the reader resumes following. + */ + var pendingCount by mutableStateOf(0) + private set + + private var lastSessionId: String? = null + private var lastTailKey: Any? = null + private var lastMessageCount: Int = 0 + + /** + * Pixel tolerance for "at bottom" detection. Kept small so a reader a few + * px above the fold is still considered not-at-bottom (no accidental pulls). + */ + var bottomPixelTolerance by mutableStateOf(8) + + /** Serialized scroll scope — all scroll jobs funnel through here. */ + fun launchScroll(block: suspend CoroutineScope.() -> Unit) { + scope.launch(block = block) + } + + /** + * Read the live bottom-follow state from [LazyListState] and react to + * user-driven scroll position changes. Should be called inside a + * `LaunchedEffect(Unit)` so it observes the list for the screen's lifetime. + */ + fun observeUserScrollPosition() { + scope.launch { + snapshotFlow { listState.isAtBottom(bottomPixelTolerance) } + .distinctUntilChanged() + .collect { atBottom -> + if (atBottom) { + isFollowingBottom = true + // Reaching the bottom means the reader caught up. + pendingCount = 0 + } else { + isFollowingBottom = false + } + } + } + } + + /** + * Call on every tail-content change (new message, streaming token, thinking + * toggle, subagent card, clarify prompt). [tailKey] must be a stable value + * that changes only when the tail content actually changes — pass + * [tailContentKey] for a ready-made key. [messageCount] is the current total + * message count, used to derive the unseen-message badge while paused. + * + * Follows the tail only when [isFollowingBottom] was true before this + * change. While paused, increments [pendingCount] by the number of new + * messages instead. + */ + fun onTailChanged( + tailKey: Any?, + messageCount: Int = 0, + ) { + if (tailKey == lastTailKey) { + lastMessageCount = messageCount + return + } + val newMessages = (messageCount - lastMessageCount).coerceAtLeast(0) + lastMessageCount = messageCount + lastTailKey = tailKey + if (isFollowingBottom) { + // Pin instantly so rapid streamed tokens don't queue competing + // animations; the reader stays glued to the growing tail. + scope.launch { listState.scrollToBottom(animated = false) } + } else { + pendingCount += newMessages + } + } + + /** Force-follow to the bottom (session switch / explicit send). Clears unread. */ + fun jumpToBottom(animated: Boolean = false) { + pendingCount = 0 + isFollowingBottom = true + scope.launch { listState.scrollToBottom(animated = animated) } + } + + /** FAB tap: resume following + clear unread. */ + fun resumeFollowing() = jumpToBottom(animated = true) + + /** True when the FAB should be visible (not following bottom + content exists). */ + fun showFab(contentPresent: Boolean): Boolean = !isFollowingBottom && contentPresent + + /** + * Current pixel scroll offset of the first visible item — captured before + * prepending older history so it can be restored after insertion. + */ + fun captureAnchorOffset(): Int = listState.firstVisibleItemScrollOffset + + /** + * Restore the viewport to a specific item (resolved by id in the caller, so + * the restore is robust even if streaming tokens arrive during the page + * insert). Keeps the reader's eye on the same content after prepend. + */ + fun scrollToItem( + index: Int, + offset: Int, + ) { + scope.launch { listState.scrollToItem(index, offset) } + } + + /** Navigate to a search match index (serialized through [scope]). */ + fun scrollToSearchMatch(index: Int) { + scope.launch { listState.animateScrollToItem(index) } + } +} + +/** + * Stable tail-key covering messages, streaming state, typing, subagent cards, + * and clarification prompts — anything that can change the list tail. + */ +fun tailContentKey( + messages: List<*>, + streamingMessage: Any?, + isThinking: Boolean, + subagentIndicators: List<*>, + clarifyRequest: Any?, +): Any = + listOf( + messages.size, + streamingMessage?.hashCode() ?: 0, + isThinking, + subagentIndicators.size, + clarifyRequest?.hashCode() ?: 0, + ) + +/** + * Continuous bottom-follow tracker derived from [LazyListState]: true when the + * last item's bottom edge is at (or within [tolerance] px of) the viewport + * bottom. Replaces the old item-count `isAtBottom(threshold)` heuristic. + */ +fun LazyListState.isAtBottom(tolerance: Int = 8): Boolean { + val layoutInfo = this.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return true + val lastItem = visibleItems.last() + val lastBottom = lastItem.offset + lastItem.size + val viewportBottom = layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding + return lastBottom >= viewportBottom - tolerance +} + +/** + * Scroll so the bottom edge of the last item is aligned to the viewport bottom. + * Top-aligns first (instant or animated), then scrolls the exact remaining gap + * so a taller-than-viewport last item's bottom stays visible. Uses the remaining + * delta (not `Int.MAX_VALUE`) to avoid integer-overflow wrap in the internal + * scroll-position clamp. + */ +suspend fun LazyListState.scrollToBottom(animated: Boolean) { + val layoutInfo = this.layoutInfo + if (layoutInfo.totalItemsCount == 0) return + val lastIndex = layoutInfo.totalItemsCount - 1 + if (animated) { + animateScrollToItem(lastIndex) + } else { + scrollToItem(lastIndex) + } + val info = this.layoutInfo + val lastItem = info.visibleItemsInfo.lastOrNull { it.index == lastIndex } ?: return + val remaining = + (lastItem.offset + lastItem.size + info.afterContentPadding) - info.viewportEndOffset + if (remaining > 0) { + if (animated) { + animateScrollBy(remaining.toFloat()) + } else { + scroll { scrollBy(remaining.toFloat()) } + } + } +} + +@Composable +fun rememberChatScrollController( + listState: LazyListState, + scope: CoroutineScope, +): ChatScrollController = remember(listState, scope) { ChatScrollController(listState, scope) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt index 86513c1b..99580189 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt @@ -5,90 +5,43 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R -import com.m57.hermescontrol.ui.chat.ChatMessage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch /** - * Returns true when the list is scrolled near its bottom - * (within [threshold] items of the last index). - */ -fun LazyListState.isAtBottom(threshold: Int = 3): Boolean { - val layoutInfo = this.layoutInfo - val visibleItems = layoutInfo.visibleItemsInfo - if (visibleItems.isEmpty()) return true - val lastVisibleItem = visibleItems.last() - return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold -} - -/** - * Scroll so the bottom edge of the last item is aligned to the bottom of the - * viewport (issue #583). + * Floating action button shown when the message list is not following the + * bottom (issue #682). Tapping it resumes bottom-follow and clears the pending + * (unseen) update count. * - * `animateScrollToItem(lastIndex)` only top-aligns the last item, so when the - * last item is taller than the viewport its bottom is clipped below the fold. - * We first top-align (instant), then scroll the exact remaining gap so the - * bottom is visible. Using the remaining delta (not `scrollOffset = Int.MAX_VALUE`) - * avoids integer overflow in the internal scroll-position clamp, which would - * otherwise wrap the offset to 0 and scroll back to the top. - */ -suspend fun LazyListState.scrollToBottom(animated: Boolean) { - val layoutInfo = this.layoutInfo - if (layoutInfo.totalItemsCount == 0) return - val lastIndex = layoutInfo.totalItemsCount - 1 - // Top-align the last item first so layoutInfo reflects the last item's offset. - // When animated, use the animated variant so the FAB / send clicks keep a - // smooth scroll instead of an instant jump followed by a tiny animation. - if (animated) { - animateScrollToItem(lastIndex) - } else { - scrollToItem(lastIndex) - } - val info = this.layoutInfo - val lastItem = info.visibleItemsInfo.lastOrNull { it.index == lastIndex } ?: return - val remaining = - (lastItem.offset + lastItem.size + info.afterContentPadding) - info.viewportEndOffset - if (remaining > 0) { - if (animated) { - animateScrollBy(remaining.toFloat()) - } else { - scroll { scrollBy(remaining.toFloat()) } - } - } -} - -/** - * Floating action button shown when the message list is not at the bottom; - * tapping scrolls the list back to the bottom. + * @param show when the reader is NOT at the bottom and there is content. + * @param pendingCount number of tail updates that arrived while follow was + * paused — rendered as a badge on the FAB. + * @param onScrollToBottom invoked on tap; should call + * [ChatScrollController.resumeFollowing]. */ @Composable fun BoxScope.ChatScrollToBottomFab( - showScrollToBottom: Boolean, - scrollScope: CoroutineScope, - listState: LazyListState, - messages: List, - streamingMessage: ChatMessage?, - isThinking: Boolean, + show: Boolean, + pendingCount: Int, + onScrollToBottom: () -> Unit, ) { AnimatedVisibility( - visible = showScrollToBottom, + visible = show, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut(), modifier = @@ -96,26 +49,27 @@ fun BoxScope.ChatScrollToBottomFab( .align(Alignment.BottomEnd) .padding(end = 16.dp, bottom = 16.dp), ) { - FloatingActionButton( - onClick = { - scrollScope.launch { - val totalItems = - messages.size + - (if (streamingMessage != null) 1 else 0) + - (if (isThinking) 1 else 0) - if (totalItems > 0) { - listState.scrollToBottom(animated = true) + BadgedBox( + badge = { + if (pendingCount > 0) { + Badge { + val label = if (pendingCount > 99) "99+" else pendingCount.toString() + Text(label) } } }, - modifier = Modifier.size(40.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainer, - contentColor = MaterialTheme.colorScheme.onSurface, ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = stringResource(R.string.content_desc_scroll_to_bottom), - ) + FloatingActionButton( + onClick = onScrollToBottom, + modifier = Modifier.size(40.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = stringResource(R.string.content_desc_scroll_to_bottom), + ) + } } } }