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 17e3bbe5..a437b894 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 @@ -114,8 +114,10 @@ data class SubagentIndicator( ) { val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done" val isFailed: Boolean get() = status == "failed" || status == "interrupted" + val isCancelled: Boolean get() = status == "cancelled" || status == "stopped" || status == "canceled" + val isSteered: Boolean get() = status == "steered" val isQueued: Boolean get() = status == "queued" - val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued + val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued } enum class MessageRole { 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 364d14d2..7a09cfb5 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 @@ -930,6 +930,8 @@ fun ChatScreen( SubagentInspectionSheet( indicators = state.subagentIndicators, todos = state.todos, + onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) }, + onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) }, onDismiss = { showSubagentInspectionSheet = false }, ) } 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 a167db16..a774705f 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 @@ -1960,6 +1960,85 @@ class ChatViewModel( } } + /** + * Send course-correction guidance to a live subagent child session (issue #1030). + */ + fun steerSubagent( + indicator: SubagentIndicator, + message: String, + ) { + val trimmed = message.trim() + if (trimmed.isBlank()) return + val sessionId = runtimeSessionId ?: return + val subagentId = indicator.subagentId + val steerCommand = + if (!subagentId.isNullOrBlank()) { + "/steer $subagentId $trimmed" + } else { + "/steer $trimmed" + } + viewModelScope.launch(ioDispatcher) { + wsClient.sendRedirect( + sessionId, + steerCommand, + onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) }, + ) + } + _uiState.update { current -> + val updated = + current.subagentIndicators.map { ind -> + if (ind.subagentId == indicator.subagentId && + (ind.goal == indicator.goal || indicator.subagentId != null) + ) { + val newLogs = + (ind.logs + SubagentLogLine(text = "Course correction: \"$trimmed\"", isSummary = true)) + .takeLast(30) + ind.copy(status = "steered", logs = newLogs) + } else { + ind + } + } + current.copy(subagentIndicators = updated) + } + } + + /** + * Stop / interrupt a single live subagent early (issue #1030). + */ + fun stopSubagent(indicator: SubagentIndicator) { + val sessionId = runtimeSessionId ?: return + val subagentId = indicator.subagentId + val stopCommand = + if (!subagentId.isNullOrBlank()) { + "/stop $subagentId" + } else { + "/stop" + } + viewModelScope.launch(ioDispatcher) { + wsClient.sendRedirect( + sessionId, + stopCommand, + onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) }, + ) + } + _uiState.update { current -> + val updated = + current.subagentIndicators.map { ind -> + if (ind.subagentId == indicator.subagentId && + (ind.goal == indicator.goal || indicator.subagentId != null) + ) { + val newLogs = + (ind.logs + SubagentLogLine(text = "Stopped subagent", isError = true)) + .takeLast(30) + ind.copy(status = "cancelled", logs = newLogs) + } else { + ind + } + } + current.copy(subagentIndicators = updated) + } + } + fun createNewSession(setLoading: Boolean = true) { // A fresh create has no persisted row until the first prompt. sessionHasServerPresence = false 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 ec0e8c7c..b820b585 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,5 +1,6 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,8 +16,12 @@ 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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.AltRoute import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle @@ -24,12 +29,18 @@ 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.HourglassEmpty import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -45,6 +56,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -54,13 +66,15 @@ import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.TodoItem /** - * Inspection sheet displaying active & completed subagent tasks and agent todos. + * Inspection sheet displaying active & completed subagent tasks and agent todos (issue #1030). */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SubagentInspectionSheet( indicators: List = emptyList(), todos: List = emptyList(), + onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null, + onStopSubagent: ((SubagentIndicator) -> Unit)? = null, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -197,7 +211,21 @@ fun SubagentInspectionSheet( "subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}" }, ) { _, indicator -> - InspectionItemCard(indicator = indicator) + InspectionItemCard( + indicator = indicator, + onSteer = + if (onSteerSubagent != null) { + { message -> onSteerSubagent(indicator, message) } + } else { + null + }, + onStop = + if (onStopSubagent != null) { + { onStopSubagent(indicator) } + } else { + null + }, + ) } } } @@ -418,29 +446,77 @@ private fun TodoInspectionCard( } @Composable -private fun InspectionItemCard(indicator: SubagentIndicator) { +private fun InspectionItemCard( + indicator: SubagentIndicator, + onSteer: ((String) -> Unit)? = null, + onStop: (() -> Unit)? = null, +) { + var showSteerInput by remember { mutableStateOf(false) } + var steerText by remember { mutableStateOf("") } + val statusColors = LocalHermesStatusColors.current + + val (statusIcon, statusTint, statusLabel) = + when { + indicator.isComplete -> { + Triple( + Icons.Filled.CheckCircle, + statusColors.success, + stringResource(R.string.subagent_status_completed), + ) + } + + indicator.isFailed -> { + Triple( + Icons.Filled.Cancel, + statusColors.error, + stringResource(R.string.subagent_status_failed), + ) + } + + indicator.isCancelled -> { + Triple( + Icons.Filled.Cancel, + statusColors.warning, + stringResource(R.string.subagent_status_cancelled), + ) + } + + indicator.isSteered -> { + Triple( + Icons.AutoMirrored.Filled.AltRoute, + MaterialTheme.colorScheme.primary, + stringResource(R.string.subagent_status_steered), + ) + } + + indicator.isQueued -> { + Triple( + Icons.Filled.HourglassEmpty, + MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(R.string.subagent_status_running), + ) + } + + else -> { + Triple( + Icons.Filled.Autorenew, + MaterialTheme.colorScheme.primary, + stringResource(R.string.subagent_status_running), + ) + } + } + Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val statusColors = LocalHermesStatusColors.current - val (statusIcon, statusTint) = - when { - indicator.isComplete -> { - Icons.Filled.CheckCircle to statusColors.success - } - - indicator.isFailed -> { - Icons.Filled.Cancel to statusColors.error - } - - else -> { - Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary - } - } + // Header Row: Status Icon, Goal, and Status Badge + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Icon( imageVector = statusIcon, contentDescription = null, @@ -456,6 +532,44 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) + + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = statusTint.copy(alpha = 0.15f), + ) { + Text( + text = statusLabel, + style = MaterialTheme.typography.labelSmall, + color = statusTint, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + + // Duration & Model info + if (indicator.durationSeconds != null || !indicator.model.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (indicator.durationSeconds != null) { + Text( + text = stringResource(R.string.subagent_duration, indicator.durationSeconds), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!indicator.model.isNullOrBlank()) { + Text( + text = indicator.model, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } + } } if (!indicator.summary.isNullOrBlank()) { @@ -467,6 +581,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { ) } + // Live Transcript Logs if (indicator.logs.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Column( @@ -503,6 +618,106 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { } } } + + // Interactive Controls for active subagent (issue #1030) + if (indicator.isRunning || indicator.isSteered) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onSteer != null) { + FilledTonalButton( + onClick = { showSteerInput = !showSteerInput }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.subagent_action_steer)) + } + } + + if (onStop != null) { + OutlinedButton( + onClick = onStop, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = statusColors.error, + ), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.subagent_action_stop)) + } + } + } + + // Inline Steering Input Field + AnimatedVisibility(visible = showSteerInput) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = steerText, + onValueChange = { steerText = it }, + placeholder = { + Text( + text = stringResource(R.string.subagent_steer_placeholder), + style = MaterialTheme.typography.bodySmall, + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = + KeyboardActions( + onSend = { + if (steerText.isNotBlank()) { + onSteer?.invoke(steerText) + steerText = "" + showSteerInput = false + } + }, + ), + modifier = Modifier.weight(1f), + textStyle = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.width(6.dp)) + IconButton( + onClick = { + if (steerText.isNotBlank()) { + onSteer?.invoke(steerText) + steerText = "" + showSteerInput = false + } + }, + enabled = steerText.isNotBlank(), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.subagent_action_steer), + tint = + if (steerText.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + }, + ) + } + } + } + } + } } } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a285a430..748d77e4 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -846,4 +846,18 @@ 이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)… 이 방의 모든 봇에 적용되는 사용자 지정 가이드라인 설정 + 에이전트 계획 + 실시간 기록 + 활성 작업 또는 계획 항목이 없습니다. + 요약: %1$s + 작업 및 계획 검사 + 안내 + 중지 + 하위 에이전트 안내 입력… + 실행 중 + 완료됨 + 안내됨 + 취소됨 + 실패 + %1$.1f초 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 15a976fe..00e6c408 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1089,6 +1089,15 @@ 没有活动任务或计划项。 摘要:%1$s 任务与计划检查 + 引导 + 停止 + 向子代理发送调整引导… + 运行中 + 已完成 + 已引导 + 已取消 + 失败 + %1$.1f秒 需要认证 清理检查点 迁移配置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f270a38..0ae0b120 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1218,6 +1218,15 @@ No active tasks or plan items. Summary: %1$s Task & Plan Inspection + Steer + Stop + Course-correction guidance for subagent… + Running + Completed + Steered + Cancelled + Failed + %1$.1fs Auth required Checkpoint prune Config migrate diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 27e20089..0382f8d4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -3939,4 +3939,68 @@ class ChatViewModelTest { .contains("Backend exploded"), ) } + + // ── Subagent steering & cancellation (issue #1030) ─────────────────── + + @Test + fun testSteerSubagent_sendsRedirectAndUpdatesStatus() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val indicator = + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-123", + goal = "Search database", + status = "running", + ) + mockEventsFlow.emit( + WsEvent.SubagentEvent( + type = "subagent.start", + payload = mapOf("subagent_id" to "sub-123", "goal" to "Search database"), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + + viewModel.steerSubagent(indicator, "Use index scan instead") + advanceUntilIdle() + + val updated = + viewModel.uiState.value.subagentIndicators + .first { it.subagentId == "sub-123" } + assertEquals("steered", updated.status) + assertTrue(updated.logs.any { it.text.contains("Use index scan instead") }) + io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/steer sub-123 Use index scan instead", any()) } + } + + @Test + fun testStopSubagent_sendsRedirectAndMarksCancelled() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + val indicator = + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-123", + goal = "Run deep scan", + status = "running", + ) + mockEventsFlow.emit( + WsEvent.SubagentEvent( + type = "subagent.start", + payload = mapOf("subagent_id" to "sub-123", "goal" to "Run deep scan"), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + + viewModel.stopSubagent(indicator) + advanceUntilIdle() + + val updated = + viewModel.uiState.value.subagentIndicators + .first { it.subagentId == "sub-123" } + assertEquals("cancelled", updated.status) + assertTrue(updated.logs.any { it.text.contains("Stopped subagent") }) + io.mockk.verify { HermesWsClient.sendRedirect(sessionId, "/stop sub-123", any()) } + } }