From e52a9c2fe6642ff76fe333765a66a107fbfb3691 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:40:54 +0200 Subject: [PATCH] feat(chat): render todo tool output as task card (#807) --- .../hermescontrol/ui/chat/TodoTaskCardTest.kt | 122 ++++++++++++++ .../m57/hermescontrol/ui/chat/TodoTaskCard.kt | 157 ++++++++++++++++++ .../m57/hermescontrol/ui/chat/ToolBubble.kt | 13 +- 3 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt new file mode 100644 index 00000000..d86e945e --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class TodoTaskCardTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun todoTool_displaysExpandedTaskCard() { + val message = + ChatMessage( + role = MessageRole.TOOL, + toolName = null, + toolStatus = ToolStatus.COMPLETED, + toolOutputRiskData = ToolOutputRiskData(risk = "high", findings = emptyList(), redacted = false), + content = + """{ + "name": "todo", + "result": { + "todos": [ + {"id": "one", "content": "Finished", "status": "completed"}, + {"id": "two", "content": "Working", "status": "in_progress"}, + {"id": "three", "content": "Later", "status": "pending"} + ], + "summary": {"total": 3, "pending": 1, "in_progress": 1, "completed": 1, "cancelled": 0} + } + }""", + ) + + composeTestRule.setContent { + HermesControlTheme { + ChatBubble(message = message, isDarkTheme = true) + } + } + + composeTestRule.onNodeWithTag("todo_task_card").assertIsDisplayed() + composeTestRule.onNodeWithText("Tasks 1/3").assertIsDisplayed() + composeTestRule.onNodeWithText("Finished").assertIsDisplayed() + composeTestRule.onNodeWithText("Working").assertIsDisplayed() + composeTestRule.onNodeWithText("Later").assertIsDisplayed() + composeTestRule.onNodeWithText("Risky output").assertIsDisplayed() + } + + @Test + fun emptyTodoOutput_usesGenericToolBubble() { + val message = + ChatMessage( + role = MessageRole.TOOL, + toolName = "todo", + toolStatus = ToolStatus.COMPLETED, + content = """{"name":"todo","result":{"todos":[]}}""", + ) + + composeTestRule.setContent { + HermesControlTheme { + ChatBubble(message = message, isDarkTheme = true) + } + } + + composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist() + } + + @Test + fun gatewayAliases_renderStructuredStatuses() { + val message = + ChatMessage( + role = MessageRole.TOOL, + toolStatus = ToolStatus.COMPLETED, + content = + """{"tool_name":"todo_write","result":{"todos":[ + {"id":"one","content":"Done alias","status":"done"}, + {"id":"two","content":"Running alias","status":"running"}, + {"id":"three","content":"Failed alias","status":"failed"}, + {"id":"four","content":"Queued alias","status":"queued"} + ]}}""", + ) + + composeTestRule.setContent { + HermesControlTheme { + ChatBubble(message = message, isDarkTheme = true) + } + } + + composeTestRule.onNodeWithText("Tasks 1/4").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Completed").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Cancelled").assertIsDisplayed() + composeTestRule.onNodeWithText("Running alias").assertIsDisplayed() + composeTestRule.onNodeWithText("Queued alias").assertIsDisplayed() + } + + @Test + fun malformedTodoOutput_usesGenericToolBubble() { + val message = + ChatMessage( + role = MessageRole.TOOL, + toolName = "todo", + toolStatus = ToolStatus.COMPLETED, + content = "not json", + ) + + composeTestRule.setContent { + HermesControlTheme { + ChatBubble(message = message, isDarkTheme = true) + } + } + + composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt new file mode 100644 index 00000000..d8948b2e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt @@ -0,0 +1,157 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +internal fun TodoTaskCard( + items: List, + isRunning: Boolean, + riskData: ToolOutputRiskData?, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(true) } + val contentColor = MaterialTheme.colorScheme.onSurfaceVariant + val completed = items.count { it.isCompleted } + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 1.dp) + .testTag("todo_task_card"), + ) { + Card( + onClick = { expanded = !expanded }, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().animateContentSize().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp, + ) + } + Text( + text = "Tasks $completed/${items.size}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse tasks" else "Expand tasks", + tint = contentColor.copy(alpha = 0.7f), + ) + } + + if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) { + SecurityRiskChip(riskData = riskData, contentColor = contentColor) + } + + if (expanded) { + items.forEach { item -> + TodoTaskRow(item = item, contentColor = contentColor) + } + } + } + } + } +} + +@Composable +private fun TodoTaskRow( + item: TodoItem, + contentColor: Color, +) { + val statusColors = LocalHermesStatusColors.current + val finished = item.isCompleted || item.isCancelled + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + when { + item.isCompleted -> + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Completed", + tint = statusColors.success, + modifier = Modifier.size(20.dp), + ) + + item.isInProgress -> + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp, + ) + + item.isCancelled -> + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Cancelled", + tint = statusColors.warning, + modifier = Modifier.size(20.dp), + ) + + else -> + Surface( + modifier = Modifier.size(20.dp), + shape = RoundedCornerShape(50), + color = Color.Transparent, + border = BorderStroke(1.5.dp, contentColor.copy(alpha = 0.45f)), + ) {} + } + + Text( + text = item.content, + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + textDecoration = if (finished) TextDecoration.LineThrough else TextDecoration.None, + modifier = Modifier.alpha(if (finished) 0.55f else 1f), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 9ae35e17..9710d9ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -113,6 +113,17 @@ internal fun ToolBubble( message: ChatMessage, modifier: Modifier = Modifier, ) { + val todoItems = extractTodosFromJson(message.content) + if (todoItems != null) { + TodoTaskCard( + items = todoItems, + isRunning = message.toolStatus == ToolStatus.RUNNING, + riskData = message.toolOutputRiskData, + modifier = modifier, + ) + return + } + var expanded by remember { mutableStateOf(false) } var showRawJson by remember { mutableStateOf(false) } val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh @@ -547,7 +558,7 @@ private fun HeaderRow( * Renders in the tool card between the header row and the summary line. */ @Composable -private fun SecurityRiskChip( +internal fun SecurityRiskChip( riskData: ToolOutputRiskData, contentColor: Color, modifier: Modifier = Modifier,