feat(chat): live subagent steering and cancellation action sheet (closes #1030) (#1034)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-09-01 18:19:00 +03:00
1 parent 908255f5ad
commit 11dd1eff92
8 files changed
+414 -20

No files matched your search

@@ -114,8 +114,10 @@ data class SubagentIndicator(
) { ) {
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done" val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
val isFailed: Boolean get() = status == "failed" || status == "interrupted" 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 isQueued: Boolean get() = status == "queued"
val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
} }
enum class MessageRole { enum class MessageRole {
@@ -930,6 +930,8 @@ fun ChatScreen(
SubagentInspectionSheet( SubagentInspectionSheet(
indicators = state.subagentIndicators, indicators = state.subagentIndicators,
todos = state.todos, todos = state.todos,
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
onDismiss = { showSubagentInspectionSheet = false }, onDismiss = { showSubagentInspectionSheet = false },
) )
} }
@@ -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) { fun createNewSession(setLoading: Boolean = true) {
// A fresh create has no persisted row until the first prompt. // A fresh create has no persisted row until the first prompt.
sessionHasServerPresence = false sessionHasServerPresence = false
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.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.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons 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.FormatListBulleted
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Autorenew 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
@@ -24,12 +29,18 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore 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.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked 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.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface 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
@@ -45,6 +56,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -54,13 +66,15 @@ import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.TodoItem 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SubagentInspectionSheet( fun SubagentInspectionSheet(
indicators: List<SubagentIndicator> = emptyList(), indicators: List<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = emptyList(), todos: List<TodoItem> = emptyList(),
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -197,7 +211,21 @@ fun SubagentInspectionSheet(
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}" "subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
}, },
) { _, indicator -> ) { _, 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 @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( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { // Header Row: Status Icon, Goal, and Status Badge
val statusColors = LocalHermesStatusColors.current Row(
val (statusIcon, statusTint) = modifier = Modifier.fillMaxWidth(),
when { verticalAlignment = Alignment.CenterVertically,
indicator.isComplete -> { ) {
Icons.Filled.CheckCircle to statusColors.success
}
indicator.isFailed -> {
Icons.Filled.Cancel to statusColors.error
}
else -> {
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
}
}
Icon( Icon(
imageVector = statusIcon, imageVector = statusIcon,
contentDescription = null, contentDescription = null,
@@ -456,6 +532,44 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f), 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()) { if (!indicator.summary.isNullOrBlank()) {
@@ -467,6 +581,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
) )
} }
// Live Transcript Logs
if (indicator.logs.isNotEmpty()) { if (indicator.logs.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Column( 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)
},
)
}
}
}
}
}
} }
} }
} }
+14
View File
@@ -846,4 +846,18 @@
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string> <string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string> <string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
<string name="group_chat_action_settings">설정</string> <string name="group_chat_action_settings">설정</string>
<string name="subagent_agent_plan">에이전트 계획</string>
<string name="subagent_live_transcript">실시간 기록</string>
<string name="subagent_no_active">활성 작업 또는 계획 항목이 없습니다.</string>
<string name="subagent_summary">요약: %1$s</string>
<string name="subagent_task_plan_inspection">작업 및 계획 검사</string>
<string name="subagent_action_steer">안내</string>
<string name="subagent_action_stop">중지</string>
<string name="subagent_steer_placeholder">하위 에이전트 안내 입력…</string>
<string name="subagent_status_running">실행 중</string>
<string name="subagent_status_completed">완료됨</string>
<string name="subagent_status_steered">안내됨</string>
<string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string>
</resources> </resources>
+9
View File
@@ -1089,6 +1089,15 @@
<string name="subagent_no_active">没有活动任务或计划项。</string> <string name="subagent_no_active">没有活动任务或计划项。</string>
<string name="subagent_summary">摘要:%1$s</string> <string name="subagent_summary">摘要:%1$s</string>
<string name="subagent_task_plan_inspection">任务与计划检查</string> <string name="subagent_task_plan_inspection">任务与计划检查</string>
<string name="subagent_action_steer">引导</string>
<string name="subagent_action_stop">停止</string>
<string name="subagent_steer_placeholder">向子代理发送调整引导…</string>
<string name="subagent_status_running">运行中</string>
<string name="subagent_status_completed">已完成</string>
<string name="subagent_status_steered">已引导</string>
<string name="subagent_status_cancelled">已取消</string>
<string name="subagent_status_failed">失败</string>
<string name="subagent_duration">%1$.1f秒</string>
<string name="system_auth_required">需要认证</string> <string name="system_auth_required">需要认证</string>
<string name="system_checkpoint_prune">清理检查点</string> <string name="system_checkpoint_prune">清理检查点</string>
<string name="system_config_migrate">迁移配置</string> <string name="system_config_migrate">迁移配置</string>
+9
View File
@@ -1218,6 +1218,15 @@
<string name="subagent_no_active">No active tasks or plan items.</string> <string name="subagent_no_active">No active tasks or plan items.</string>
<string name="subagent_summary">Summary: %1$s</string> <string name="subagent_summary">Summary: %1$s</string>
<string name="subagent_task_plan_inspection">Task &amp; Plan Inspection</string> <string name="subagent_task_plan_inspection">Task &amp; Plan Inspection</string>
<string name="subagent_action_steer">Steer</string>
<string name="subagent_action_stop">Stop</string>
<string name="subagent_steer_placeholder">Course-correction guidance for subagent…</string>
<string name="subagent_status_running">Running</string>
<string name="subagent_status_completed">Completed</string>
<string name="subagent_status_steered">Steered</string>
<string name="subagent_status_cancelled">Cancelled</string>
<string name="subagent_status_failed">Failed</string>
<string name="subagent_duration">%1$.1fs</string>
<string name="system_auth_required">Auth required</string> <string name="system_auth_required">Auth required</string>
<string name="system_checkpoint_prune">Checkpoint prune</string> <string name="system_checkpoint_prune">Checkpoint prune</string>
<string name="system_config_migrate">Config migrate</string> <string name="system_config_migrate">Config migrate</string>
@@ -3939,4 +3939,68 @@ class ChatViewModelTest {
.contains("Backend exploded"), .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()) }
}
} }