feat(chat): render todo tool output as task card (#807)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 19:40:54 +03:00
1 parent ebba7ff654
commit e52a9c2fe6
3 files changed
+291 -1

No files matched your search

@@ -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()
}
}
@@ -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<TodoItem>,
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),
)
}
}
@@ -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,