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:
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() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user