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 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
2e97ee9a84
commit
cc8f8a2773
4 files changed
+335
-155
No files matched your search
@@ -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<Pair<String, Int>?>(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) {
|
||||
|
||||
+14
-56
@@ -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<ChatMessage>,
|
||||
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<String?>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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<ChatMessage>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user