feat(bots): lightweight tool execution indicators in group chat (#1008)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-29 19:11:14 +03:00
1 parent 0d09f43469
commit fbe602cb4f
4 files changed
+249 -11

No files matched your search

@@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.bots.group
import com.m57.hermescontrol.data.model.BotAvatarMeta
import com.m57.hermescontrol.data.model.ProfileInfo
import java.util.UUID
/**
* Lightweight tool execution indicator for group chat turns.
*/
data class GroupChatToolCall(
val id: String = UUID.randomUUID().toString(),
val name: String,
val summary: String? = null,
val isRunning: Boolean = false,
)
/**
* Single message entry in a multi-agent group room.
@@ -17,6 +28,7 @@ data class GroupChatMessage(
val isStreaming: Boolean = false,
val thread: String? = null,
val isSystem: Boolean = false,
val toolCalls: List<GroupChatToolCall> = emptyList(),
)
/**
@@ -24,7 +24,11 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -56,6 +60,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -542,8 +547,19 @@ private fun GroupMessageCard(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (message.toolCalls.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp),
) {
message.toolCalls.forEach { tool ->
GroupChatToolChip(tool = tool)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
if (message.isStreaming && message.text.isBlank()) {
if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp),
@@ -571,3 +587,56 @@ private fun GroupMessageCard(
}
}
}
@Composable
private fun GroupChatToolChip(
tool: GroupChatToolCall,
modifier: Modifier = Modifier,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
modifier = modifier,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val icon =
when (tool.name.lowercase()) {
"terminal" -> Icons.Filled.Terminal
"web_search", "search_files" -> Icons.Filled.Search
"read_file", "write_file", "patch" -> Icons.Filled.Description
else -> Icons.Filled.Build
}
Icon(
imageVector = icon,
contentDescription = tool.name,
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.primary,
)
val label =
if (!tool.summary.isNullOrBlank()) {
"${tool.name}: ${tool.summary}"
} else {
tool.name
}
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (tool.isRunning) {
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -107,6 +107,19 @@ class GroupChatViewModel(
loadGroup()
}
private fun extractToolSummary(data: Map<String, Any?>?): String? {
if (data == null) return null
val cmd = (data["command"] as? String)?.trim()
if (!cmd.isNullOrBlank()) return cmd.take(35)
val query = (data["query"] as? String)?.trim()
if (!query.isNullOrBlank()) return query.take(35)
val path = (data["path"] as? String)?.trim()
if (!path.isNullOrBlank()) return path.take(35)
val pattern = (data["pattern"] as? String)?.trim()
if (!pattern.isNullOrBlank()) return pattern.take(35)
return null
}
private fun observeWsEvents() {
viewModelScope.launch(ioDispatcher) {
HermesWsClient.events.collect { event ->
@@ -146,6 +159,93 @@ class GroupChatViewModel(
}
}
is WsEvent.ToolStart -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid]
val toolName = event.name ?: "tool"
val toolId =
(event.data?.get("tool_id") as? String)?.ifBlank { null }
?: UUID.randomUUID().toString()
val summary = extractToolSummary(event.data)
val toolCall =
GroupChatToolCall(
id = toolId,
name = toolName,
summary = summary,
isRunning = true,
)
if (streamId != null && bot != null) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamId }
val updatedMessages =
if (existing != null) {
state.messages.map { msg ->
if (msg.id == streamId) {
msg.copy(toolCalls = msg.toolCalls + toolCall)
} else {
msg
}
}
} else {
state.messages +
GroupChatMessage(
id = streamId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = "",
isStreaming = true,
toolCalls = listOf(toolCall),
)
}
state.copy(messages = updatedMessages)
}
}
}
}
is WsEvent.ToolComplete -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
val streamId = activeStreamMsgId[sid]
val toolId = event.data?.get("tool_id") as? String
val toolName = event.name
if (streamId != null) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamId }
if (existing != null) {
val updatedTools =
existing.toolCalls.map { tool ->
if ((toolId != null && tool.id == toolId) ||
(toolId == null && tool.name == toolName && tool.isRunning) ||
(toolId == null && toolName == null && tool.isRunning)
) {
tool.copy(isRunning = false)
} else {
tool
}
}
state.copy(
messages =
state.messages.map { msg ->
if (msg.id == streamId) {
msg.copy(toolCalls = updatedTools)
} else {
msg
}
},
)
} else {
state
}
}
}
}
}
is WsEvent.MessageComplete -> {
val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) {
@@ -617,18 +717,21 @@ class GroupChatViewModel(
}
if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) {
val finalMsg =
GroupChatMessage(
id = streamMsgId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = replyText.trim(),
isStreaming = false,
)
_uiState.update { state ->
val existing = state.messages.find { it.id == streamMsgId }
val finalTools =
existing?.toolCalls?.map { it.copy(isRunning = false) } ?: emptyList()
val finalMsg =
GroupChatMessage(
id = streamMsgId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
avatarMeta = bot.botMeta()?.avatar,
text = replyText.trim(),
isStreaming = false,
toolCalls = finalTools,
)
val updatedList =
if (existing != null) {
state.messages.map { if (it.id == streamMsgId) finalMsg else it }