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 72652381..b416f463 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 @@ -52,6 +52,30 @@ data class ChatMessage( val progressPreview: String? = null, ) +/** + * Single live transcript log entry for subagent execution. + */ +data class SubagentLogLine( + val timestamp: Long = System.currentTimeMillis(), + val text: String, + val isError: Boolean = false, + val isSummary: Boolean = false, +) + +/** + * Representation of a single task/todo item in the agent's plan. + */ +data class TodoItem( + val id: String, + val content: String, + val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled" +) { + 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" +} + /** * Representation of subagent execution details for transient UI indicators. * @@ -63,10 +87,18 @@ data class SubagentIndicator( val taskIndex: Int? = null, val taskCount: Int? = null, val text: String? = null, // preview line - val status: String? = null, // complete only + val status: String? = null, // running / queued / completed / failed val summary: String? = null, // complete only val subagentId: String? = null, -) + val logs: List = emptyList(), + val durationSeconds: Double? = null, + val model: String? = null, +) { + val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done" + val isFailed: Boolean get() = status == "failed" || status == "interrupted" + val isQueued: Boolean get() = status == "queued" + val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued +} enum class MessageRole { USER, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 86d4de40..b84ae955 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -84,6 +84,8 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay import com.m57.hermescontrol.ui.chat.components.ReloginDialog import com.m57.hermescontrol.ui.chat.components.SearchBarRow +import com.m57.hermescontrol.ui.chat.components.StickySubagentBar +import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.common.AutoScrollingTitleText @@ -186,6 +188,7 @@ fun ChatScreen( streamingState.streamingMessage, streamingState.isThinking, state.subagentIndicators, + state.todos, state.clarifyRequest, ) { scrollController.onTailChanged( @@ -211,6 +214,7 @@ fun ChatScreen( var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } + var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) } var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() @@ -469,6 +473,15 @@ fun ChatScreen( } } + StickySubagentBar( + indicators = state.subagentIndicators, + todos = state.todos, + onClick = { + showSubagentInspectionSheet = true + scrollController.resumeFollowing() + }, + ) + Box( modifier = Modifier @@ -493,7 +506,6 @@ fun ChatScreen( lastAnimatedMessageId = lastAnimatedMessageId, onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, viewModel = viewModel, - subagentIndicators = state.subagentIndicators, clarifyRequest = state.clarifyRequest, onRespondClarify = viewModel::respondToClarify, onDismissClarify = viewModel::dismissClarify, @@ -641,6 +653,14 @@ fun ChatScreen( ) } + if (showSubagentInspectionSheet) { + SubagentInspectionSheet( + indicators = state.subagentIndicators, + todos = state.todos, + onDismiss = { showSubagentInspectionSheet = false }, + ) + } + viewingImage?.let { image -> ImageViewerDialog( image = image, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 62e32369..1b7101df 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -114,6 +114,8 @@ data class ChatUiState( val reactionTriggerId: Long = 0L, /** Subagent delegation indicators (issue #538) — transient UI state. */ val subagentIndicators: List = emptyList(), + /** Agent todo / plan items (issue #736). */ + val todos: List = emptyList(), ) { /** Convenience — derived from [connectionStatus]. */ val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED 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 55d7fac6..3abb8b0f 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 @@ -4,6 +4,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.toJsonElement import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive /** * Pure state reducer for WebSocket events. @@ -42,7 +45,18 @@ object ChatWsEventReducer { if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { return ReducerResult(state = state, streamingState = streamingState) } - return reduceInternal(state, streamingState, event) + val result = reduceInternal(state, streamingState, event) + val hydratedTodos = + if (result.state.todos.isEmpty() && result.state.messages.isNotEmpty()) { + hydrateTodosFromMessages(result.state.messages) + } else { + result.state.todos + } + return if (hydratedTodos !== result.state.todos) { + result.copy(state = result.state.copy(todos = hydratedTodos)) + } else { + result + } } private fun reduceInternal( @@ -134,6 +148,13 @@ object ChatWsEventReducer { ) val effects = mutableListOf() + val cleanedSubagents = + if (state.subagentIndicators.all { it.isComplete || it.isFailed }) { + emptyList() + } else { + state.subagentIndicators + } + // Build new state: finalize any orphan streaming message, then set the new one var orphan: ChatMessage? = null val preState = @@ -143,10 +164,12 @@ object ChatWsEventReducer { state.copy( messages = state.messages + finalized, isAgentTyping = true, + subagentIndicators = cleanedSubagents, ) } else { state.copy( isAgentTyping = true, + subagentIndicators = cleanedSubagents, ) } val newStreamingState = @@ -361,8 +384,11 @@ object ChatWsEventReducer { effects.add(ReducerEffect.PersistMessage(toolMessage, sid)) } + val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null + val nextTodos = parsedTodos ?: newState.todos + return ReducerResult( - state = newState.copy(messages = newState.messages + toolMessage), + state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos), effects = effects, ) } @@ -399,8 +425,12 @@ object ChatWsEventReducer { if (sid != null) { effects.add(ReducerEffect.PersistMessage(updated, sid)) } + + val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null + val nextTodos = parsedTodos ?: state.todos + return ReducerResult( - state = state.copy(messages = messages), + state = state.copy(messages = messages, todos = nextTodos), streamingState = streamingState, effects = effects, ) @@ -600,6 +630,8 @@ object ChatWsEventReducer { val text = event.payload?.get("text") as? String val status = event.payload?.get("status") as? String val summary = event.payload?.get("summary") as? String + val durationSeconds = (event.payload?.get("duration_seconds") as? Number)?.toDouble() + val model = event.payload?.get("model") as? String val subagentId = event.payload?.get( "subagent_id", @@ -615,18 +647,22 @@ object ChatWsEventReducer { -1 } - // A completed delegation is no longer "in flight" — drop its indicator - // so it doesn't linger in the chat after the subagent finishes. - if (event.type == "subagent.complete") { - if (idx >= 0) { - indicators.removeAt(idx) - return ReducerResult( - state = state.copy(subagentIndicators = indicators), - streamingState = streamingState, - ) - } - return ReducerResult(state = state, streamingState = streamingState) + val existingLogs = if (idx >= 0) indicators[idx].logs else emptyList() + val newLogs = existingLogs.toMutableList() + if (!text.isNullOrBlank() && newLogs.lastOrNull()?.text != text) { + newLogs.add(SubagentLogLine(text = text, isError = status == "failed")) } + if (event.type == "subagent.complete" && !summary.isNullOrBlank() && newLogs.lastOrNull()?.text != summary) { + newLogs.add(SubagentLogLine(text = summary, isSummary = true)) + } + val trimmedLogs = newLogs.takeLast(30) + + val finalStatus = + if (event.type == "subagent.complete") { + status ?: "completed" + } else { + status ?: (if (idx >= 0) indicators[idx].status else "running") + } val indicator = SubagentIndicator( @@ -634,10 +670,13 @@ object ChatWsEventReducer { goal = goal ?: (if (idx >= 0) indicators[idx].goal else null), taskIndex = taskIndex ?: (if (idx >= 0) indicators[idx].taskIndex else null), taskCount = taskCount ?: (if (idx >= 0) indicators[idx].taskCount else null), - text = text ?: (if (idx >= 0) indicators[idx].text else null), - status = status ?: (if (idx >= 0) indicators[idx].status else null), + text = summary ?: text ?: (if (idx >= 0) indicators[idx].text else null), + status = finalStatus, summary = summary ?: (if (idx >= 0) indicators[idx].summary else null), subagentId = subagentId ?: (if (idx >= 0) indicators[idx].subagentId else null), + logs = trimmedLogs, + durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null), + model = model ?: (if (idx >= 0) indicators[idx].model else null), ) if (idx >= 0) { @@ -701,3 +740,62 @@ sealed class ReducerEffect { val messageId: String, ) : ReducerEffect() } + +@Suppress("UNCHECKED_CAST") +fun extractTodosFromMap(data: Map?): List? { + if (data == null) return null + val rawList = + (data["todos"] as? List<*>) + ?: ((data["args"] as? Map)?.get("todos") as? List<*>) + ?: ((data["parameters"] as? Map)?.get("todos") as? List<*>) + ?: ((data["result"] as? Map)?.get("todos") as? List<*>) + ?: return null + + val items = mutableListOf() + for (elem in rawList) { + val map = elem as? Map ?: continue + 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)) + } + return items.takeIf { it.isNotEmpty() } +} + +fun extractTodosFromJson(content: String): List? { + if (content.isBlank()) return null + return try { + val element = OkHttpProvider.json.parseToJsonElement(content) + val obj = element as? JsonObject ?: return null + val todosArray = + (obj["todos"] as? JsonArray) + ?: ((obj["args"] as? JsonObject)?.get("todos") as? JsonArray) + ?: ((obj["result"] as? JsonObject)?.get("todos") as? JsonArray) + ?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray) + ?: return null + + todosArray.mapNotNull { item -> + val itemObj = item as? JsonObject ?: return@mapNotNull null + val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null + val contentStr = + (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) + }.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } +} + +fun hydrateTodosFromMessages(messages: List): List { + for (msg in messages.asReversed()) { + if (msg.role == MessageRole.TOOL && (msg.toolName == "todo" || msg.content.contains("\"todos\""))) { + val todos = extractTodosFromJson(msg.content) + if (!todos.isNullOrEmpty()) { + return todos + } + } + } + return emptyList() +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index 884cde58..0d1bca38 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -22,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.MessageRole -import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.common.EmptyState /** @@ -47,7 +46,6 @@ fun ChatMessageList( lastAnimatedMessageId: String?, onLastAnimatedMessageIdChange: (String?) -> Unit, viewModel: ChatViewModel, - subagentIndicators: List = emptyList(), clarifyRequest: ClarifyUi? = null, onRespondClarify: ((String) -> Unit)? = null, onDismissClarify: (() -> Unit)? = null, @@ -145,14 +143,6 @@ fun ChatMessageList( } } - // Subagent indicators — SubagentCard replaces SubagentIndicatorRow - items( - items = subagentIndicators, - key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" }, - ) { indicator -> - SubagentCard(indicator = indicator) - } - // Clarify bubble — rendered at the very bottom if (clarifyRequest != null) { item(key = "clarify_bubble") { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/StickySubagentBar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/StickySubagentBar.kt new file mode 100644 index 00000000..02d64385 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/StickySubagentBar.kt @@ -0,0 +1,150 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ElectricBolt +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.ui.chat.SubagentIndicator +import com.m57.hermescontrol.ui.chat.TodoItem + +/** + * Compact sticky progress indicator displayed below the top app bar + * when background subagents or agent todos are in progress. + */ +@Composable +fun StickySubagentBar( + indicators: List = emptyList(), + todos: List = emptyList(), + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val activeSubagents = indicators.count { it.isRunning } + val activeTodos = todos.count { it.isInProgress } + val isVisible = activeSubagents > 0 || activeTodos > 0 + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = modifier, + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .testTag("sticky_subagent_bar"), + color = MaterialTheme.colorScheme.tertiaryContainer, + shadowElevation = 2.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.ElectricBolt, + contentDescription = "Active Tasks", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + + Spacer(modifier = Modifier.width(8.dp)) + + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + val labelText = + when { + activeSubagents > 0 && activeTodos > 0 -> { + "$activeSubagents Subagents · $activeTodos Task Active" + } + activeSubagents > 0 -> { + val activeSub = indicators.firstOrNull { it.isRunning } + val goalText = activeSub?.goal?.takeIf { it.isNotBlank() } + val countStr = + if (activeSubagents == 1) { + "1 Subagent Active" + } else { + "$activeSubagents Subagents Active" + } + if (goalText != null) "$countStr · $goalText" else countStr + } + else -> { + val activeTodo = todos.firstOrNull { it.isInProgress } + val todoText = activeTodo?.content?.takeIf { it.isNotBlank() } + if (todoText != null) "Task In Progress: $todoText" else "1 Task In Progress" + } + } + + Text( + text = labelText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Inspect", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontSize = 11.sp, + ) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Inspect", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + } + } + } + } +} 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 new file mode 100644 index 00000000..a24a1304 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -0,0 +1,289 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ElectricBolt +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.ui.chat.SubagentIndicator +import com.m57.hermescontrol.ui.chat.TodoItem + +/** + * Inspection sheet displaying active & completed subagent tasks and agent todos. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubagentInspectionSheet( + indicators: List = emptyList(), + todos: List = emptyList(), + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag("subagent_inspection_sheet"), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ElectricBolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Task & Plan Inspection", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (indicators.isEmpty() && todos.isEmpty()) { + Text( + text = "No active tasks or plan items.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (todos.isNotEmpty()) { + item(key = "todos_header") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.FormatListBulleted, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "AGENT PLAN", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + itemsIndexed( + items = todos, + key = { index, todo -> "todo-${todo.id}_$index" }, + ) { _, todo -> + TodoInspectionCard(todo = todo) + } + } + + if (indicators.isNotEmpty()) { + if (todos.isNotEmpty()) { + item(key = "subagents_header") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) { + Icon( + imageVector = Icons.Default.ElectricBolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "SUBAGENTS", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + itemsIndexed( + items = indicators, + key = { index, indicator -> + "subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}" + }, + ) { _, indicator -> + InspectionItemCard(indicator = indicator) + } + } + } + } + } + } +} + +@Composable +private fun TodoInspectionCard(todo: TodoItem) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val statusSymbol = + when { + todo.isCompleted -> "✅" + todo.isInProgress -> "⚡" + todo.isCancelled -> "❌" + else -> "⭕" + } + Text(text = statusSymbol, fontSize = 16.sp) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = todo.content, + style = + 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) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun InspectionItemCard(indicator: SubagentIndicator) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val statusSymbol = + when { + indicator.isComplete -> "✅" + indicator.isFailed -> "❌" + else -> "⚡" + } + Text(text = statusSymbol, fontSize = 16.sp) + Spacer(modifier = Modifier.width(8.dp)) + + val taskIndexStr = indicator.taskIndex?.let { "#$it " } ?: "" + Text( + text = "$taskIndexStr${indicator.goal ?: "Subagent Task"}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + } + + if (!indicator.summary.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Summary: ${indicator.summary}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (indicator.logs.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "LIVE TRANSCRIPT", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.outline, + fontSize = 9.sp, + ) + indicator.logs.forEach { logLine -> + Text( + text = "› ${logLine.text}", + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + color = + if (logLine.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + } +} 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 439c4230..8042004e 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 @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat import com.m57.hermescontrol.data.ws.WsEvent import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class ChatWsEventReducerTest { @@ -161,7 +162,7 @@ class ChatWsEventReducerTest { } @Test - fun testSubagentComplete_removesIndicator() { + fun testSubagentComplete_updatesIndicatorToCompleted() { val initialIndicator = SubagentIndicator( type = "subagent.start", @@ -196,7 +197,12 @@ class ChatWsEventReducerTest { currentSessionId = "session-1", ) - assertEquals(0, result.state.subagentIndicators.size) + assertEquals(1, result.state.subagentIndicators.size) + val indicator = result.state.subagentIndicators.first() + assertEquals("subagent.complete", indicator.type) + assertEquals("completed", indicator.status) + assertEquals("done", indicator.summary) + assertTrue(indicator.isComplete) } @Test @@ -233,4 +239,140 @@ class ChatWsEventReducerTest { assertEquals(ToolStatus.RUNNING, updatedMessage.toolStatus) assertEquals(null, updatedMessage.progressPreview) } + + @Test + fun testSubagentEvent_accumulatesLiveTranscriptLogs() { + val state = + ChatUiState( + currentSessionId = "session-1", + subagentIndicators = emptyList(), + ) + val startEvent = + WsEvent.SubagentEvent( + type = "subagent.start", + sessionId = "session-1", + payload = + mapOf( + "subagent_id" to "sub-1", + "goal" to "research api", + "text" to "Initializing subagent", + ), + ) + + val res1 = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = startEvent, + currentSessionId = "session-1", + ) + + val progressEvent = + WsEvent.SubagentEvent( + type = "subagent.progress", + sessionId = "session-1", + payload = + mapOf( + "subagent_id" to "sub-1", + "text" to "Fetching documentation", + ), + ) + + val res2 = + ChatWsEventReducer.reduce( + state = res1.state, + streamingState = StreamingState(), + event = progressEvent, + currentSessionId = "session-1", + ) + + val indicator = res2.state.subagentIndicators.first() + assertEquals(2, indicator.logs.size) + assertEquals("Initializing subagent", indicator.logs[0].text) + assertEquals("Fetching documentation", indicator.logs[1].text) + assertTrue(indicator.isRunning) + } + + @Test + fun testMessageStart_prunesCompletedSubagents() { + val completedSubagent = + SubagentIndicator( + type = "subagent.complete", + goal = "finished task", + subagentId = "sub-1", + status = "completed", + ) + val state = + ChatUiState( + currentSessionId = "session-1", + subagentIndicators = listOf(completedSubagent), + ) + val startEvent = WsEvent.MessageStart(sessionId = "session-1") + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = startEvent, + currentSessionId = "session-1", + ) + + assertTrue(result.state.subagentIndicators.isEmpty()) + } + + @Test + fun testToolStart_extractsAgentTodos() { + val state = ChatUiState(currentSessionId = "session-1") + val todoEvent = + WsEvent.ToolStart( + name = "todo", + sessionId = "session-1", + data = + mapOf( + "todos" to + listOf( + mapOf("id" to "1", "content" to "Inspect repo", "status" to "completed"), + mapOf("id" to "2", "content" to "Implement feature", "status" to "in_progress"), + ), + ), + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = todoEvent, + currentSessionId = "session-1", + ) + + assertEquals(2, result.state.todos.size) + assertEquals("Inspect repo", result.state.todos[0].content) + assertTrue(result.state.todos[0].isCompleted) + assertEquals("Implement feature", result.state.todos[1].content) + assertTrue(result.state.todos[1].isInProgress) + } + + @Test + fun testHydrateTodosFromMessages_parsesStoredToolMessage() { + val todoMessage = + ChatMessage( + role = MessageRole.TOOL, + toolName = "todo", + content = """{"todos":[{"id":"a","content":"Write tests","status":"completed"}]}""", + ) + val state = ChatUiState(messages = listOf(todoMessage), currentSessionId = "session-1") + val event = WsEvent.MessageToken(token = "hello", sessionId = "session-1") + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertEquals(1, result.state.todos.size) + assertEquals("Write tests", result.state.todos[0].content) + assertTrue(result.state.todos[0].isCompleted) + } }