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 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 {
@@ -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 },
)
}
@@ -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
@@ -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<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = 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)
},
)
}
}
}
}
}
}
}
}
+14
View File
@@ -846,4 +846,18 @@
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</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>
+9
View File
@@ -1089,6 +1089,15 @@
<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>
<string name="system_auth_required">需要认证</string>
<string name="system_checkpoint_prune">清理检查点</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_summary">Summary: %1$s</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_checkpoint_prune">Checkpoint prune</string>
<string name="system_config_migrate">Config migrate</string>
@@ -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()) }
}
}