Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
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
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
||||||
@@ -189,6 +190,15 @@ object EventParser {
|
|||||||
WsEvent.SessionUsage(payload, sessionId)
|
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" -> {
|
"reaction" -> {
|
||||||
val kind = payload?.get("kind") as? String ?: ""
|
val kind = payload?.get("kind") as? String ?: ""
|
||||||
WsEvent.ReactionEvent(kind)
|
WsEvent.ReactionEvent(kind)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||||
|
|
||||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||||
sealed class WsEvent {
|
sealed class WsEvent {
|
||||||
// ── Gateway lifecycle ────────────────────────────────────────────────
|
// ── Gateway lifecycle ────────────────────────────────────────────────
|
||||||
@@ -183,6 +185,17 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : 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 ────────────────────────────────────────────────────
|
// ── RPC responses ────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class RpcResult(
|
data class RpcResult(
|
||||||
|
|||||||
@@ -85,11 +85,13 @@ data class TodoItem(
|
|||||||
val id: String,
|
val id: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
|
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
|
||||||
|
val parent: String? = null,
|
||||||
) {
|
) {
|
||||||
val isCompleted: Boolean get() = status == "completed" || status == "done"
|
val isCompleted: Boolean get() = status == "completed" || status == "done"
|
||||||
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
|
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
|
||||||
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
|
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
|
||||||
val isPending: Boolean get() = status == "pending" || status == "queued"
|
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.SessionUsage -> onSessionUsage(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.TodoUpdated -> onTodoUpdated(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.GatewayError -> onGatewayError(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(
|
private fun onSessionUsage(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
@@ -957,7 +973,8 @@ fun extractTodosFromMap(data: Map<String, Any?>?): List<TodoItem>? {
|
|||||||
val id = (map["id"] as? String) ?: continue
|
val id = (map["id"] as? String) ?: continue
|
||||||
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
|
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
|
||||||
val status = (map["status"] as? String) ?: "pending"
|
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() }
|
return items.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
@@ -982,7 +999,8 @@ fun extractTodosFromJson(content: String): List<TodoItem>? {
|
|||||||
(itemObj["content"] as? JsonPrimitive)?.content
|
(itemObj["content"] as? JsonPrimitive)?.content
|
||||||
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
|
||||||
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
|
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() }
|
}.takeIf { it.isNotEmpty() }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
|
|||||||
+194
-14
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Cancel
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Groups
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -30,6 +34,10 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -57,6 +65,9 @@ fun SubagentInspectionSheet(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
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(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -111,9 +122,9 @@ fun SubagentInspectionSheet(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
if (todos.isNotEmpty()) {
|
if (displayRows.isNotEmpty()) {
|
||||||
item(key = "todos_header") {
|
item(key = "todos_header") {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -134,11 +145,26 @@ fun SubagentInspectionSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
items(
|
||||||
items = todos,
|
items = displayRows,
|
||||||
key = { index, todo -> "todo-${todo.id}_$index" },
|
key = { row -> "todo-${row.todo.id}_${row.depth}" },
|
||||||
) { _, todo ->
|
) { row ->
|
||||||
TodoInspectionCard(todo = todo)
|
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
|
@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(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = startPadding)
|
||||||
|
.then(
|
||||||
|
if (onToggleExpand != null) {
|
||||||
|
Modifier.clickable(onClick = onToggleExpand)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color =
|
||||||
|
if (depth > 0) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
@@ -211,19 +353,35 @@ private fun TodoInspectionCard(todo: TodoItem) {
|
|||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
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(
|
Icon(
|
||||||
imageVector = statusIcon,
|
imageVector = statusIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = statusTint,
|
tint = statusTint,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(if (depth > 0) 16.dp else 18.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
text = todo.content,
|
text = todo.content,
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
if (depth > 0) {
|
||||||
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
|
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,
|
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
|
||||||
color =
|
color =
|
||||||
if (todo.isCompleted) {
|
if (todo.isCompleted) {
|
||||||
@@ -233,6 +391,28 @@ private fun TodoInspectionCard(todo: TodoItem) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
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 id = firstString(item, listOf("id"))
|
||||||
val content = firstString(item, listOf("content"))
|
val content = firstString(item, listOf("content"))
|
||||||
val status = firstString(item, listOf("status")).ifEmpty { "pending" }
|
val status = firstString(item, listOf("status")).ifEmpty { "pending" }
|
||||||
|
val parent = firstString(item, listOf("parent"))
|
||||||
val marker =
|
val marker =
|
||||||
when (status) {
|
when (status) {
|
||||||
"completed" -> "[x]"
|
"completed" -> "[x]"
|
||||||
@@ -1776,7 +1777,8 @@ object ToolViewBuilder {
|
|||||||
"cancelled" -> "[~]"
|
"cancelled" -> "[~]"
|
||||||
else -> "[ ]"
|
else -> "[ ]"
|
||||||
}
|
}
|
||||||
"$marker $id. $content"
|
val indent = if (parent.isNotEmpty()) " ↳ " else ""
|
||||||
|
"$indent$marker $id. $content"
|
||||||
}.joinToString("\n")
|
}.joinToString("\n")
|
||||||
.takeIf { it.isNotEmpty() }
|
.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.mockk.unmockkAll
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -700,4 +701,49 @@ class EventParserTest {
|
|||||||
assertEquals("terminal", toolEvent.name)
|
assertEquals("terminal", toolEvent.name)
|
||||||
assertEquals("sess-replay-2", toolEvent.sessionId)
|
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(1, result.state.compressionCount)
|
||||||
assertEquals(null, result.state.usedContextTokens)
|
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
|
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.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 com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -142,4 +145,72 @@ class TaskProgressChipTest {
|
|||||||
)
|
)
|
||||||
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user