diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index a95da949..c85fe7ea 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 57558b90..4b0a4ab3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -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 = emptyList(), + val revision: Int? = null, + val sessionId: String? = null, + ) : WsEvent() + // ── RPC responses ──────────────────────────────────────────────────── data class RpcResult( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 21577ca1..17e3bbe5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -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() } /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index acf51088..1ce569d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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?): List? { 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? { (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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index a81658cc..ec0e8c7c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -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()) } + 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 = 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): List { + val itemsById = todos.associateBy { it.id } + val childrenByParent = mutableMapOf>() + val rootItems = mutableListOf() + + 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 { + val descendants = mutableListOf() + for (child in node.children) { + descendants.add(child.item) + descendants.addAll(getAllDescendantTodos(child)) + } + return descendants +} + +fun flattenTodoTree( + nodes: List, + collapsedParentIds: Set, +): List { + val result = mutableListOf() + + 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), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 5abee578..2ce9af42 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -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() } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 5b152e34..dc922da5 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index da930fb0..eec260cd 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt index 43692f21..72173a10 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt @@ -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) + } }