feat(chat): support hierarchical nested subtasks in Todos via parent property (#1018) (#1028)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-31 22:09:15 +03:00
1 parent 3fd9ac767f
commit ceb4813218
9 files changed
+443 -17

No files matched your search

@@ -1,6 +1,7 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
/**
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
@@ -189,6 +190,15 @@ object EventParser {
WsEvent.SessionUsage(payload, sessionId)
}
"todo.updated" -> {
val revision =
(payload?.get("revision") as? Number)?.toInt()
?: (params["revision"] as? Number)?.toInt()
val rawTodos = payload ?: params
val todos = extractTodosFromMap(rawTodos) ?: emptyList()
WsEvent.TodoUpdated(todos, revision, sessionId)
}
"reaction" -> {
val kind = payload?.get("kind") as? String ?: ""
WsEvent.ReactionEvent(kind)
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.ui.chat.TodoItem
/** Parsed WebSocket events emitted by [HermesWsClient]. */
sealed class WsEvent {
// ── Gateway lifecycle ────────────────────────────────────────────────
@@ -183,6 +185,17 @@ sealed class WsEvent {
val sessionId: String? = null,
) : WsEvent()
/**
* Revisioned todo snapshot event emitted by backend todo tool updates (issue #1018).
* Event: `todo.updated`
* Payload: `{ "todos": [...], "revision": Int }`
*/
data class TodoUpdated(
val todos: List<TodoItem> = emptyList(),
val revision: Int? = null,
val sessionId: String? = null,
) : WsEvent()
// ── RPC responses ────────────────────────────────────────────────────
data class RpcResult(
@@ -85,11 +85,13 @@ data class TodoItem(
val id: String,
val content: String,
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
val parent: String? = null,
) {
val isCompleted: Boolean get() = status == "completed" || status == "done"
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
val isPending: Boolean get() = status == "pending" || status == "queued"
val isSubtask: Boolean get() = !parent.isNullOrBlank()
}
/**
@@ -134,6 +134,8 @@ object ChatWsEventReducer {
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
is WsEvent.TodoUpdated -> onTodoUpdated(state, streamingState, event)
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
@@ -870,6 +872,20 @@ object ChatWsEventReducer {
)
}
private fun onTodoUpdated(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.TodoUpdated,
): ReducerResult {
if (event.sessionId != null && state.currentSessionId != null && event.sessionId != state.currentSessionId) {
return ReducerResult(state = state, streamingState = streamingState)
}
return ReducerResult(
state = state.copy(todos = event.todos),
streamingState = streamingState,
)
}
private fun onSessionUsage(
state: ChatUiState,
streamingState: StreamingState,
@@ -957,7 +973,8 @@ fun extractTodosFromMap(data: Map<String, Any?>?): List<TodoItem>? {
val id = (map["id"] as? String) ?: continue
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
val status = (map["status"] as? String) ?: "pending"
items.add(TodoItem(id = id, content = content, status = status))
val parent = (map["parent"] as? String)?.takeIf { it.isNotBlank() }
items.add(TodoItem(id = id, content = content, status = status, parent = parent))
}
return items.takeIf { it.isNotEmpty() }
}
@@ -982,7 +999,8 @@ fun extractTodosFromJson(content: String): List<TodoItem>? {
(itemObj["content"] as? JsonPrimitive)?.content
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
TodoItem(id = id, content = contentStr, status = status)
val parent = (itemObj["parent"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
TodoItem(id = id, content = contentStr, status = status, parent = parent)
}.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -19,6 +21,8 @@ import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
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.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -30,6 +34,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.clip
@@ -57,6 +65,9 @@ fun SubagentInspectionSheet(
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val todoTree = remember(todos) { buildTodoTree(todos) }
var collapsedParentIds by remember { mutableStateOf(setOf<String>()) }
val displayRows = remember(todoTree, collapsedParentIds) { flattenTodoTree(todoTree, collapsedParentIds) }
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -111,9 +122,9 @@ fun SubagentInspectionSheet(
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (todos.isNotEmpty()) {
if (displayRows.isNotEmpty()) {
item(key = "todos_header") {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -134,11 +145,26 @@ fun SubagentInspectionSheet(
)
}
}
itemsIndexed(
items = todos,
key = { index, todo -> "todo-${todo.id}_$index" },
) { _, todo ->
TodoInspectionCard(todo = todo)
items(
items = displayRows,
key = { row -> "todo-${row.todo.id}_${row.depth}" },
) { row ->
TodoInspectionCard(
row = row,
onToggleExpand =
if (row.hasChildren) {
{
collapsedParentIds =
if (row.todo.id in collapsedParentIds) {
collapsedParentIds - row.todo.id
} else {
collapsedParentIds + row.todo.id
}
}
} else {
null
},
)
}
}
@@ -180,12 +206,128 @@ fun SubagentInspectionSheet(
}
}
/**
* Node in the hierarchical todo tree.
*/
data class TodoNode(
val item: TodoItem,
val children: List<TodoNode> = emptyList(),
val depth: Int = 0,
)
/**
* Flattened row representation ready for lazy column rendering with indentation and collapse state.
*/
data class TodoDisplayRow(
val todo: TodoItem,
val depth: Int,
val hasChildren: Boolean,
val isExpanded: Boolean,
val completedSubtasksCount: Int,
val totalSubtasksCount: Int,
)
/**
* Constructs a hierarchical tree from a flat list of [TodoItem]s using the [TodoItem.parent] property.
*/
fun buildTodoTree(todos: List<TodoItem>): List<TodoNode> {
val itemsById = todos.associateBy { it.id }
val childrenByParent = mutableMapOf<String, MutableList<TodoItem>>()
val rootItems = mutableListOf<TodoItem>()
for (item in todos) {
val p = item.parent
if (p != null && itemsById.containsKey(p) && p != item.id) {
childrenByParent.getOrPut(p) { mutableListOf() }.add(item)
} else {
rootItems.add(item)
}
}
fun buildNode(
item: TodoItem,
depth: Int,
): TodoNode {
val children = childrenByParent[item.id]?.map { buildNode(it, depth + 1) } ?: emptyList()
return TodoNode(item = item, children = children, depth = depth)
}
return rootItems.map { buildNode(it, 0) }
}
fun getAllDescendantTodos(node: TodoNode): List<TodoItem> {
val descendants = mutableListOf<TodoItem>()
for (child in node.children) {
descendants.add(child.item)
descendants.addAll(getAllDescendantTodos(child))
}
return descendants
}
fun flattenTodoTree(
nodes: List<TodoNode>,
collapsedParentIds: Set<String>,
): List<TodoDisplayRow> {
val result = mutableListOf<TodoDisplayRow>()
fun traverse(node: TodoNode) {
val isExpanded = node.item.id !in collapsedParentIds
val descendants = getAllDescendantTodos(node)
val completedCount = descendants.count { it.isCompleted }
val totalCount = descendants.size
result.add(
TodoDisplayRow(
todo = node.item,
depth = node.depth,
hasChildren = node.children.isNotEmpty(),
isExpanded = isExpanded,
completedSubtasksCount = completedCount,
totalSubtasksCount = totalCount,
),
)
if (node.children.isNotEmpty() && isExpanded) {
for (child in node.children) {
traverse(child)
}
}
}
for (root in nodes) {
traverse(root)
}
return result
}
@Composable
private fun TodoInspectionCard(todo: TodoItem) {
private fun TodoInspectionCard(
row: TodoDisplayRow,
onToggleExpand: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val todo = row.todo
val depth = row.depth
val startPadding = (depth * 20).coerceAtMost(60).dp
Surface(
modifier = Modifier.fillMaxWidth(),
modifier =
modifier
.fillMaxWidth()
.padding(start = startPadding)
.then(
if (onToggleExpand != null) {
Modifier.clickable(onClick = onToggleExpand)
} else {
Modifier
},
),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
color =
if (depth > 0) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
) {
Row(
modifier = Modifier.padding(12.dp),
@@ -211,19 +353,35 @@ private fun TodoInspectionCard(todo: TodoItem) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
}
if (depth > 0) {
Text(
text = "↳",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(end = 6.dp),
)
}
Icon(
imageVector = statusIcon,
contentDescription = null,
tint = statusTint,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(if (depth > 0) 16.dp else 18.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = todo.content,
style =
MaterialTheme.typography.bodyMedium.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
),
if (depth > 0) {
MaterialTheme.typography.bodySmall.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
)
} else {
MaterialTheme.typography.bodyMedium.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
)
},
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
color =
if (todo.isCompleted) {
@@ -233,6 +391,28 @@ private fun TodoInspectionCard(todo: TodoItem) {
},
modifier = Modifier.weight(1f),
)
if (row.hasChildren) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
) {
Text(
text = "${row.completedSubtasksCount}/${row.totalSubtasksCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = if (row.isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (row.isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@@ -1769,6 +1769,7 @@ object ToolViewBuilder {
val id = firstString(item, listOf("id"))
val content = firstString(item, listOf("content"))
val status = firstString(item, listOf("status")).ifEmpty { "pending" }
val parent = firstString(item, listOf("parent"))
val marker =
when (status) {
"completed" -> "[x]"
@@ -1776,7 +1777,8 @@ object ToolViewBuilder {
"cancelled" -> "[~]"
else -> "[ ]"
}
"$marker $id. $content"
val indent = if (parent.isNotEmpty()) " ↳ " else ""
"$indent$marker $id. $content"
}.joinToString("\n")
.takeIf { it.isNotEmpty() }
}
@@ -6,6 +6,7 @@ import io.mockk.unmockkAll
import kotlinx.serialization.json.JsonObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -700,4 +701,49 @@ class EventParserTest {
assertEquals("terminal", toolEvent.name)
assertEquals("sess-replay-2", toolEvent.sessionId)
}
@Test
fun testParseTodoUpdated_returnsTodoUpdatedEvent() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "todo.updated",
"session_id" to "sess-abc",
"payload" to
mapOf(
"revision" to 5,
"todos" to
listOf(
mapOf("id" to "1", "content" to "Parent task", "status" to "in_progress"),
mapOf(
"id" to "2",
"content" to "Sub task",
"status" to "pending",
"parent" to "1",
),
),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.TodoUpdated)
val todoUpdated = event as WsEvent.TodoUpdated
assertEquals("sess-abc", todoUpdated.sessionId)
assertEquals(5, todoUpdated.revision)
assertEquals(2, todoUpdated.todos.size)
assertEquals("1", todoUpdated.todos[0].id)
assertEquals("Parent task", todoUpdated.todos[0].content)
assertEquals(null, todoUpdated.todos[0].parent)
assertFalse(todoUpdated.todos[0].isSubtask)
assertEquals("2", todoUpdated.todos[1].id)
assertEquals("Sub task", todoUpdated.todos[1].content)
assertEquals("1", todoUpdated.todos[1].parent)
assertTrue(todoUpdated.todos[1].isSubtask)
}
}
@@ -979,4 +979,88 @@ class ChatWsEventReducerTest {
assertEquals(1, result.state.compressionCount)
assertEquals(null, result.state.usedContextTokens)
}
@Test
fun testTodoUpdated_updatesStateTodos() {
val state = ChatUiState(currentSessionId = "session-1")
val todos =
listOf(
TodoItem(id = "1", content = "Main task", status = "in_progress"),
TodoItem(id = "2", content = "Subtask 1", status = "completed", parent = "1"),
TodoItem(id = "3", content = "Subtask 2", status = "pending", parent = "1"),
)
val event = WsEvent.TodoUpdated(todos = todos, revision = 1, sessionId = "session-1")
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(3, result.state.todos.size)
assertEquals("Main task", result.state.todos[0].content)
assertEquals(null, result.state.todos[0].parent)
assertEquals("1", result.state.todos[1].parent)
assertTrue(result.state.todos[1].isCompleted)
assertTrue(result.state.todos[1].isSubtask)
}
@Test
fun testTodoUpdated_differentSession_isIgnored() {
val state =
ChatUiState(
currentSessionId = "session-1",
todos = listOf(TodoItem(id = "orig", content = "Original", status = "pending")),
)
val event =
WsEvent.TodoUpdated(
todos = listOf(TodoItem(id = "new", content = "New", status = "in_progress")),
revision = 1,
sessionId = "session-other",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.todos.size)
assertEquals("Original", result.state.todos[0].content)
}
@Test
fun testExtractTodosFromMap_preservesParentHierarchy() {
val payload =
mapOf(
"todos" to
listOf(
mapOf("id" to "t1", "content" to "Root", "status" to "in_progress"),
mapOf("id" to "t2", "content" to "Child", "status" to "pending", "parent" to "t1"),
),
)
val extracted = extractTodosFromMap(payload)
assertEquals(2, extracted?.size)
assertEquals("t1", extracted?.get(0)?.id)
assertEquals(null, extracted?.get(0)?.parent)
assertEquals("t2", extracted?.get(1)?.id)
assertEquals("t1", extracted?.get(1)?.parent)
}
@Test
fun testExtractTodosFromJson_preservesParentHierarchy() {
val json =
"""{"todos":[{"id":"a","content":"Task A","status":"done"},""" +
"""{"id":"b","content":"Task B","status":"pending","parent":"a"}]}"""
val extracted = extractTodosFromJson(json)
assertEquals(2, extracted?.size)
assertEquals("a", extracted?.get(0)?.id)
assertEquals(null, extracted?.get(0)?.parent)
assertEquals("b", extracted?.get(1)?.id)
assertEquals("a", extracted?.get(1)?.parent)
}
}
@@ -1,6 +1,9 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.components.buildTodoTree
import com.m57.hermescontrol.ui.chat.components.computeChipDisplay
import com.m57.hermescontrol.ui.chat.components.flattenTodoTree
import com.m57.hermescontrol.ui.chat.components.getAllDescendantTodos
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -142,4 +145,72 @@ class TaskProgressChipTest {
)
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
}
@Test
fun `buildTodoTree constructs correct multi-level hierarchy`() {
val todos =
listOf(
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
TodoItem(id = "1.2.1", content = "Grandchild 1.2.1", status = "pending", parent = "1.2"),
TodoItem(id = "2", content = "Root 2", status = "pending"),
)
val tree = buildTodoTree(todos)
assertEquals(2, tree.size)
assertEquals("1", tree[0].item.id)
assertEquals(0, tree[0].depth)
assertEquals(2, tree[0].children.size)
assertEquals("1.1", tree[0].children[0].item.id)
assertEquals(1, tree[0].children[0].depth)
assertEquals("1.2", tree[0].children[1].item.id)
assertEquals(1, tree[0].children[1].depth)
assertEquals(1, tree[0].children[1].children.size)
assertEquals(
"1.2.1",
tree[0]
.children[1]
.children[0]
.item.id,
)
assertEquals(2, tree[0].children[1].children[0].depth)
assertEquals("2", tree[1].item.id)
assertEquals(0, tree[1].depth)
}
@Test
fun `flattenTodoTree collapses and expands subtasks correctly`() {
val todos =
listOf(
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
TodoItem(id = "2", content = "Root 2", status = "pending"),
)
val tree = buildTodoTree(todos)
// Expanded state
val expandedRows = flattenTodoTree(tree, collapsedParentIds = emptySet())
assertEquals(4, expandedRows.size)
assertEquals("1", expandedRows[0].todo.id)
assertTrue(expandedRows[0].hasChildren)
assertTrue(expandedRows[0].isExpanded)
assertEquals(1, expandedRows[0].completedSubtasksCount)
assertEquals(2, expandedRows[0].totalSubtasksCount)
assertEquals("1.1", expandedRows[1].todo.id)
assertEquals(1, expandedRows[1].depth)
assertEquals("1.2", expandedRows[2].todo.id)
assertEquals(1, expandedRows[2].depth)
assertEquals("2", expandedRows[3].todo.id)
assertEquals(0, expandedRows[3].depth)
// Collapsed state
val collapsedRows = flattenTodoTree(tree, collapsedParentIds = setOf("1"))
assertEquals(2, collapsedRows.size)
assertEquals("1", collapsedRows[0].todo.id)
assertTrue(collapsedRows[0].hasChildren)
assertFalse(collapsedRows[0].isExpanded)
assertEquals("2", collapsedRows[1].todo.id)
}
}