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)
},
)
}
}
}
}
}
}
}
}