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() }
}