feat(group-chat): expand tool execution details and terminal output preview (#1058)

This commit is contained in:
M57 authored and GitHub committed 2026-09-04 14:52:15 +03:00
1 parent bd0b4c2d55
commit 7ae8977dc3
6 files changed
+168 -8

No files matched your search

@@ -12,6 +12,10 @@ data class GroupChatToolCall(
val name: String, val name: String,
val summary: String? = null, val summary: String? = null,
val isRunning: Boolean = false, val isRunning: Boolean = false,
val command: String? = null,
val output: String? = null,
val exitCode: Int? = null,
val isError: Boolean = false,
) )
/** /**
@@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -37,6 +38,8 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -78,6 +81,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -1021,20 +1025,39 @@ private fun GroupChatToolChip(
tool: GroupChatToolCall, tool: GroupChatToolCall,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var expanded by rememberSaveable(tool.id) { mutableStateOf(false) }
val isTerminal =
tool.name.equals("terminal", ignoreCase = true) ||
tool.name.equals("execute_code", ignoreCase = true)
Surface( Surface(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), border =
modifier = modifier, BorderStroke(
1.dp,
if (tool.isError) {
MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
},
),
modifier =
modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) { ) {
val icon = val icon =
when (tool.name.lowercase()) { when (tool.name.lowercase()) {
"terminal" -> Icons.Filled.Terminal "terminal", "execute_code" -> Icons.Filled.Terminal
"web_search", "search_files" -> Icons.Filled.Search "web_search", "search_files" -> Icons.Filled.Search
"read_file", "write_file", "patch" -> Icons.Filled.Description "read_file", "write_file", "patch" -> Icons.Filled.Description
else -> Icons.Filled.Build else -> Icons.Filled.Build
@@ -1043,7 +1066,12 @@ private fun GroupChatToolChip(
imageVector = icon, imageVector = icon,
contentDescription = tool.name, contentDescription = tool.name,
modifier = Modifier.size(13.dp), modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.primary, tint =
if (tool.isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
) )
val label = val label =
if (!tool.summary.isNullOrBlank()) { if (!tool.summary.isNullOrBlank()) {
@@ -1057,6 +1085,7 @@ private fun GroupChatToolChip(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
if (tool.isRunning) { if (tool.isRunning) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -1064,6 +1093,81 @@ private fun GroupChatToolChip(
strokeWidth = 1.5.dp, strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} else {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!tool.command.isNullOrBlank()) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
) {
SelectionContainer {
Text(
text = if (isTerminal) "$ ${tool.command}" else tool.command,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
}
}
val outputText =
when {
tool.isRunning -> stringResource(R.string.group_chat_tool_running)
!tool.output.isNullOrBlank() -> tool.output.trim()
tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success)
else -> stringResource(R.string.group_chat_tool_no_output)
}
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth(),
) {
SelectionContainer {
Text(
text = outputText,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
color =
if (tool.isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 15,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
}
}
} }
} }
} }
@@ -112,6 +112,21 @@ class GroupChatViewModel(
loadGroup() loadGroup()
} }
private fun extractToolCommand(data: Map<String, Any?>?): String? {
if (data == null) return null
val cmd = (data["command"] as? String)?.trim()
if (!cmd.isNullOrBlank()) return cmd
val code = (data["code"] as? String)?.trim()
if (!code.isNullOrBlank()) return code
val query = (data["query"] as? String)?.trim()
if (!query.isNullOrBlank()) return query
val path = (data["path"] as? String)?.trim()
if (!path.isNullOrBlank()) return path
val pattern = (data["pattern"] as? String)?.trim()
if (!pattern.isNullOrBlank()) return pattern
return null
}
private fun extractToolSummary(data: Map<String, Any?>?): String? { private fun extractToolSummary(data: Map<String, Any?>?): String? {
if (data == null) return null if (data == null) return null
val cmd = (data["command"] as? String)?.trim() val cmd = (data["command"] as? String)?.trim()
@@ -174,11 +189,13 @@ class GroupChatViewModel(
(event.data?.get("tool_id") as? String)?.ifBlank { null } (event.data?.get("tool_id") as? String)?.ifBlank { null }
?: UUID.randomUUID().toString() ?: UUID.randomUUID().toString()
val summary = extractToolSummary(event.data) val summary = extractToolSummary(event.data)
val command = extractToolCommand(event.data)
val toolCall = val toolCall =
GroupChatToolCall( GroupChatToolCall(
id = toolId, id = toolId,
name = toolName, name = toolName,
summary = summary, summary = summary,
command = command,
isRunning = true, isRunning = true,
) )
if (streamId != null && bot != null) { if (streamId != null && bot != null) {
@@ -218,6 +235,20 @@ class GroupChatViewModel(
val streamId = activeStreamMsgId[sid] val streamId = activeStreamMsgId[sid]
val toolId = event.data?.get("tool_id") as? String val toolId = event.data?.get("tool_id") as? String
val toolName = event.name val toolName = event.name
val output =
(event.data?.get("output") as? String)
?: (event.data?.get("stdout") as? String)
?: (event.data?.get("result") as? String)
val exitCode =
when (val rawCode = event.data?.get("exit_code")) {
is Number -> rawCode.toInt()
is String -> rawCode.toIntOrNull()
else -> null
}
val isError =
(exitCode != null && exitCode != 0) ||
(event.data?.get("is_error") as? Boolean == true) ||
(event.data?.get("error") != null)
if (streamId != null) { if (streamId != null) {
_uiState.update { state -> _uiState.update { state ->
val existing = state.messages.find { it.id == streamId } val existing = state.messages.find { it.id == streamId }
@@ -228,7 +259,12 @@ class GroupChatViewModel(
(toolId == null && tool.name == toolName && tool.isRunning) || (toolId == null && tool.name == toolName && tool.isRunning) ||
(toolId == null && toolName == null && tool.isRunning) (toolId == null && toolName == null && tool.isRunning)
) { ) {
tool.copy(isRunning = false) tool.copy(
isRunning = false,
output = output ?: tool.output,
exitCode = exitCode ?: tool.exitCode,
isError = isError || tool.isError,
)
} else { } else {
tool tool
} }
+3
View File
@@ -1169,6 +1169,9 @@
<string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string> <string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string>
<string name="group_chat_thinking">%1$s يفكّر الآن…</string> <string name="group_chat_thinking">%1$s يفكّر الآن…</string>
<string name="group_chat_typing">جارٍ الكتابة…</string> <string name="group_chat_typing">جارٍ الكتابة…</string>
<string name="group_chat_tool_running">جارٍ التشغيل…</string>
<string name="group_chat_tool_no_output">لا يوجد مخرجات</string>
<string name="group_chat_tool_no_output_success">رمز الخروج 0 (لا يوجد مخرجات)</string>
<string name="group_chat_members_count">%1$d أعضاء</string> <string name="group_chat_members_count">%1$d أعضاء</string>
<string name="group_chat_mention_all">@all</string> <string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة</string> <string name="group_chat_capped_limit">المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة</string>
+3
View File
@@ -1284,6 +1284,9 @@
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string> <string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
<string name="group_chat_thinking">%1$s is thinking…</string> <string name="group_chat_thinking">%1$s is thinking…</string>
<string name="group_chat_typing">Typing…</string> <string name="group_chat_typing">Typing…</string>
<string name="group_chat_tool_running">Running…</string>
<string name="group_chat_tool_no_output">No output</string>
<string name="group_chat_tool_no_output_success">Exit code 0 (no output)</string>
<string name="group_chat_members_count">%1$d members</string> <string name="group_chat_members_count">%1$d members</string>
<string name="group_chat_mention_all">@all</string> <string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string> <string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string>
@@ -500,13 +500,19 @@ class GroupChatViewModelTest {
val toolCall = msgWithTool.toolCalls.first() val toolCall = msgWithTool.toolCalls.first()
assertEquals("terminal", toolCall.name) assertEquals("terminal", toolCall.name)
assertEquals("date", toolCall.summary) assertEquals("date", toolCall.summary)
assertEquals("date", toolCall.command)
assertTrue(toolCall.isRunning) assertTrue(toolCall.isRunning)
// Emit ToolComplete // Emit ToolComplete
eventsFlow.emit( eventsFlow.emit(
WsEvent.ToolComplete( WsEvent.ToolComplete(
name = "terminal", name = "terminal",
data = mapOf("tool_id" to "tool-123"), data =
mapOf(
"tool_id" to "tool-123",
"output" to "Sat Aug 29 03:30:00 UTC 2026",
"exit_code" to 0,
),
sessionId = "session-scout-1", sessionId = "session-scout-1",
), ),
) )
@@ -515,7 +521,11 @@ class GroupChatViewModelTest {
val msgAfterToolDone = val msgAfterToolDone =
viewModel.uiState.value.messages viewModel.uiState.value.messages
.last() .last()
assertFalse(msgAfterToolDone.toolCalls.first().isRunning) val completedTool = msgAfterToolDone.toolCalls.first()
assertFalse(completedTool.isRunning)
assertEquals("Sat Aug 29 03:30:00 UTC 2026", completedTool.output)
assertEquals(0, completedTool.exitCode)
assertFalse(completedTool.isError)
// Complete message // Complete message
eventsFlow.emit( eventsFlow.emit(