feat(bots): smart streaming auto-scroll and scroll-to-bottom FAB in group chat (#1013)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-30 00:21:02 +03:00
1 parent 97b6641d84
commit 5c8ab7ed6c
1 file changed
+44 -6
@@ -77,6 +77,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.chat.formatTimestamp
import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
@@ -95,6 +98,8 @@ fun GroupChatScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue("")) mutableStateOf(TextFieldValue(""))
} }
@@ -104,6 +109,7 @@ fun GroupChatScreen(
val handleSend = { val handleSend = {
val text = inputFieldValue.text val text = inputFieldValue.text
if (text.isNotBlank()) { if (text.isNotBlank()) {
scrollController.jumpToBottom()
viewModel.sendMessage(text) viewModel.sendMessage(text)
inputFieldValue = TextFieldValue("") inputFieldValue = TextFieldValue("")
} }
@@ -113,10 +119,32 @@ fun GroupChatScreen(
viewModel.setGroup(groupName) viewModel.setGroup(groupName)
} }
LaunchedEffect(state.messages.size) { LaunchedEffect(Unit) {
if (state.messages.isNotEmpty()) { scrollController.observeUserScrollPosition()
listState.animateScrollToItem(state.messages.size - 1)
} }
val streamingMessage = state.messages.find { it.isStreaming }
val tailKey =
remember(
state.messages.size,
streamingMessage?.id,
streamingMessage?.text?.length,
state.activeSpeaker,
) {
tailContentKey(
messages = state.messages,
streamingMessage = streamingMessage?.text,
isThinking = state.activeSpeaker != null,
subagentIndicators = emptyList<Any>(),
clarifyRequest = null,
)
}
LaunchedEffect(tailKey) {
scrollController.onTailChanged(
tailKey = tailKey,
messageCount = state.messages.size,
)
} }
HermesScaffold( HermesScaffold(
@@ -204,13 +232,16 @@ fun GroupChatScreen(
) )
} }
CompositionLocalProvider(LocalDensity provides chatDensity) { Box(
LazyColumn(
state = listState,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
) {
CompositionLocalProvider(LocalDensity provides chatDensity) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -242,6 +273,13 @@ fun GroupChatScreen(
} }
} }
} }
ChatScrollToBottomFab(
show = !scrollController.isFollowingBottom && state.messages.isNotEmpty(),
pendingCount = scrollController.pendingCount,
onScrollToBottom = { scrollController.resumeFollowing() },
)
}
} }
} }