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.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
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.AutoScrollingTitleText
|
||||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
@@ -121,6 +122,8 @@ fun ChatScreen(
|
|||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val scrollScope = rememberCoroutineScope()
|
||||||
|
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||||
|
|
||||||
// Periodic session sync while connected.
|
// Periodic session sync while connected.
|
||||||
@@ -132,6 +135,10 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Arm + trigger older-message paging when the user scrolls near the top.
|
// 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) {
|
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
|
||||||
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
|
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
|
||||||
snapshotFlow { listState.firstVisibleItemIndex }
|
snapshotFlow { listState.firstVisibleItemIndex }
|
||||||
@@ -140,16 +147,59 @@ fun ChatScreen(
|
|||||||
if (firstVisibleIndex > 2) {
|
if (firstVisibleIndex > 2) {
|
||||||
isOlderPagingArmed = true
|
isOlderPagingArmed = true
|
||||||
} else if (isOlderPagingArmed) {
|
} 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()
|
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 {
|
val showScrollToBottom by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
state.messages.isNotEmpty() && listState.canScrollForward
|
scrollController.showFab(state.messages.isNotEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val scrollScope = rememberCoroutineScope()
|
|
||||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
mutableStateOf(TextFieldValue(""))
|
mutableStateOf(TextFieldValue(""))
|
||||||
}
|
}
|
||||||
@@ -277,8 +327,6 @@ fun ChatScreen(
|
|||||||
connectionStatus = state.connectionStatus,
|
connectionStatus = state.connectionStatus,
|
||||||
currentSessionId = state.currentSessionId,
|
currentSessionId = state.currentSessionId,
|
||||||
messages = state.messages,
|
messages = state.messages,
|
||||||
streamingMessage = streamingState.streamingMessage,
|
|
||||||
isThinking = streamingState.isThinking,
|
|
||||||
errorMessage = state.errorMessage,
|
errorMessage = state.errorMessage,
|
||||||
backgroundCompleteMessage = state.backgroundCompleteMessage,
|
backgroundCompleteMessage = state.backgroundCompleteMessage,
|
||||||
isSearchActive = state.isSearchActive,
|
isSearchActive = state.isSearchActive,
|
||||||
@@ -288,6 +336,7 @@ fun ChatScreen(
|
|||||||
sudoPrompt = state.sudoPrompt,
|
sudoPrompt = state.sudoPrompt,
|
||||||
secretPrompt = state.secretPrompt,
|
secretPrompt = state.secretPrompt,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
|
scrollController = scrollController,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
)
|
)
|
||||||
@@ -446,14 +495,12 @@ fun ChatScreen(
|
|||||||
// Loading overlay
|
// Loading overlay
|
||||||
ChatLoadingOverlay(isLoading = state.isLoading)
|
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(
|
ChatScrollToBottomFab(
|
||||||
showScrollToBottom = showScrollToBottom,
|
show = showScrollToBottom,
|
||||||
scrollScope = scrollScope,
|
pendingCount = scrollController.pendingCount,
|
||||||
listState = listState,
|
onScrollToBottom = scrollController::resumeFollowing,
|
||||||
messages = state.messages,
|
|
||||||
streamingMessage = streamingState.streamingMessage,
|
|
||||||
isThinking = streamingState.isThinking,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reaction heartsanimation (purely cosmetic — fades out
|
// Reaction heartsanimation (purely cosmetic — fades out
|
||||||
@@ -471,15 +518,8 @@ fun ChatScreen(
|
|||||||
onSend = {
|
onSend = {
|
||||||
viewModel.sendMessage(inputFieldValue.text)
|
viewModel.sendMessage(inputFieldValue.text)
|
||||||
inputFieldValue = TextFieldValue("")
|
inputFieldValue = TextFieldValue("")
|
||||||
scrollScope.launch {
|
// Jump to bottom after send (serialized through the controller).
|
||||||
val totalItems =
|
scrollController.jumpToBottom(animated = true)
|
||||||
state.messages.size +
|
|
||||||
(if (streamingState.streamingMessage != null) 1 else 0) +
|
|
||||||
(if (streamingState.isThinking) 1 else 0)
|
|
||||||
if (totalItems > 0) {
|
|
||||||
listState.scrollToBottom(animated = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onMicTap = {
|
onMicTap = {
|
||||||
if (isListening) {
|
if (isListening) {
|
||||||
|
|||||||
+14
-56
@@ -11,12 +11,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
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.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
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.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.SecretPromptUi
|
import com.m57.hermescontrol.ui.chat.SecretPromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.SudoPromptUi
|
import com.m57.hermescontrol.ui.chat.SudoPromptUi
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatLifecycleEffects(
|
fun ChatLifecycleEffects(
|
||||||
@@ -38,8 +31,6 @@ fun ChatLifecycleEffects(
|
|||||||
connectionStatus: ConnectionStatus,
|
connectionStatus: ConnectionStatus,
|
||||||
currentSessionId: String?,
|
currentSessionId: String?,
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: ChatMessage?,
|
|
||||||
isThinking: Boolean,
|
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
backgroundCompleteMessage: String?,
|
backgroundCompleteMessage: String?,
|
||||||
isSearchActive: Boolean,
|
isSearchActive: Boolean,
|
||||||
@@ -49,6 +40,7 @@ fun ChatLifecycleEffects(
|
|||||||
sudoPrompt: SudoPromptUi?,
|
sudoPrompt: SudoPromptUi?,
|
||||||
secretPrompt: SecretPromptUi?,
|
secretPrompt: SecretPromptUi?,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
|
scrollController: ChatScrollController,
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
) {
|
) {
|
||||||
@@ -61,19 +53,24 @@ fun ChatLifecycleEffects(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Switch to session from notification/history
|
// Switch to session from notification/history
|
||||||
var lastSessionId by remember { mutableStateOf<String?>(null) }
|
LaunchedEffect(sessionId, NavigationController.pendingSessionId, connectionStatus) {
|
||||||
val pendingSessionId = NavigationController.pendingSessionId
|
|
||||||
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
|
|
||||||
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
|
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()) {
|
if (!target.isNullOrBlank()) {
|
||||||
viewModel.switchSession(target)
|
viewModel.switchSession(target)
|
||||||
if (target == pendingSessionId) {
|
if (target == NavigationController.pendingSessionId) {
|
||||||
NavigationController.pendingSessionId = null
|
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
|
// Lifecycle observer for notification foreground service
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer =
|
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
|
// Show error as snackbar
|
||||||
LaunchedEffect(errorMessage) {
|
LaunchedEffect(errorMessage) {
|
||||||
errorMessage?.let { error ->
|
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) {
|
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
|
||||||
if (isSearchActive &&
|
if (isSearchActive &&
|
||||||
currentSearchMatchIndex >= 0 &&
|
currentSearchMatchIndex >= 0 &&
|
||||||
@@ -191,9 +151,7 @@ fun ChatLifecycleEffects(
|
|||||||
messages.isNotEmpty()
|
messages.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
|
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
|
||||||
listState.animateScrollToItem(
|
scrollController.scrollToSearchMatch(targetIndex.coerceIn(0, messages.lastIndex))
|
||||||
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.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
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.BoxScope
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
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.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
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
|
* Floating action button shown when the message list is not following the
|
||||||
* (within [threshold] items of the last index).
|
* bottom (issue #682). Tapping it resumes bottom-follow and clears the pending
|
||||||
*/
|
* (unseen) update count.
|
||||||
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).
|
|
||||||
*
|
*
|
||||||
* `animateScrollToItem(lastIndex)` only top-aligns the last item, so when the
|
* @param show when the reader is NOT at the bottom and there is content.
|
||||||
* last item is taller than the viewport its bottom is clipped below the fold.
|
* @param pendingCount number of tail updates that arrived while follow was
|
||||||
* We first top-align (instant), then scroll the exact remaining gap so the
|
* paused — rendered as a badge on the FAB.
|
||||||
* bottom is visible. Using the remaining delta (not `scrollOffset = Int.MAX_VALUE`)
|
* @param onScrollToBottom invoked on tap; should call
|
||||||
* avoids integer overflow in the internal scroll-position clamp, which would
|
* [ChatScrollController.resumeFollowing].
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.ChatScrollToBottomFab(
|
fun BoxScope.ChatScrollToBottomFab(
|
||||||
showScrollToBottom: Boolean,
|
show: Boolean,
|
||||||
scrollScope: CoroutineScope,
|
pendingCount: Int,
|
||||||
listState: LazyListState,
|
onScrollToBottom: () -> Unit,
|
||||||
messages: List<ChatMessage>,
|
|
||||||
streamingMessage: ChatMessage?,
|
|
||||||
isThinking: Boolean,
|
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showScrollToBottom,
|
visible = show,
|
||||||
enter = fadeIn() + scaleIn(),
|
enter = fadeIn() + scaleIn(),
|
||||||
exit = fadeOut() + scaleOut(),
|
exit = fadeOut() + scaleOut(),
|
||||||
modifier =
|
modifier =
|
||||||
@@ -96,26 +49,27 @@ fun BoxScope.ChatScrollToBottomFab(
|
|||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 16.dp, bottom = 16.dp),
|
.padding(end = 16.dp, bottom = 16.dp),
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
BadgedBox(
|
||||||
onClick = {
|
badge = {
|
||||||
scrollScope.launch {
|
if (pendingCount > 0) {
|
||||||
val totalItems =
|
Badge {
|
||||||
messages.size +
|
val label = if (pendingCount > 99) "99+" else pendingCount.toString()
|
||||||
(if (streamingMessage != null) 1 else 0) +
|
Text(label)
|
||||||
(if (isThinking) 1 else 0)
|
|
||||||
if (totalItems > 0) {
|
|
||||||
listState.scrollToBottom(animated = true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
FloatingActionButton(
|
||||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
onClick = onScrollToBottom,
|
||||||
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
|
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