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
|
||||
|
||||
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
|
||||
|
||||
+194
-14
@@ -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() }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user