feat(chat): render todo tool output as task card (#807)
This commit is contained in:
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,
|
message: ChatMessage,
|
||||||
modifier: Modifier = Modifier,
|
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 expanded by remember { mutableStateOf(false) }
|
||||||
var showRawJson by remember { mutableStateOf(false) }
|
var showRawJson by remember { mutableStateOf(false) }
|
||||||
val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
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.
|
* Renders in the tool card between the header row and the summary line.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SecurityRiskChip(
|
internal fun SecurityRiskChip(
|
||||||
riskData: ToolOutputRiskData,
|
riskData: ToolOutputRiskData,
|
||||||
contentColor: Color,
|
contentColor: Color,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|||||||
Reference in new issue
Block a user