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
+73 -35
@@ -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,43 +232,53 @@ fun GroupChatScreen(
) )
} }
CompositionLocalProvider(LocalDensity provides chatDensity) { Box(
LazyColumn( modifier =
state = listState, Modifier
modifier = .weight(1f)
Modifier .fillMaxWidth(),
.weight(1f) ) {
.fillMaxWidth(), CompositionLocalProvider(LocalDensity provides chatDensity) {
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp), state = listState,
) { modifier = Modifier.fillMaxSize(),
items( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
items = state.messages, verticalArrangement = Arrangement.spacedBy(12.dp),
key = { it.id }, ) {
) { message -> items(
GroupMessageCard(message = message) items = state.messages,
} key = { it.id },
) { message ->
GroupMessageCard(message = message)
}
state.activeSpeaker?.let { speaker -> state.activeSpeaker?.let { speaker ->
item(key = "active_speaker") { item(key = "active_speaker") {
Row( Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp), modifier = Modifier.padding(start = 8.dp, top = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.group_chat_thinking, speaker), text = stringResource(R.string.group_chat_thinking, speaker),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
}
} }
} }
} }
} }
ChatScrollToBottomFab(
show = !scrollController.isFollowingBottom && state.messages.isNotEmpty(),
pendingCount = scrollController.pendingCount,
onScrollToBottom = { scrollController.resumeFollowing() },
)
} }
} }
} }