feat(group-chat): expand tool execution details and terminal output preview (#1058)
This commit is contained in:
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user