feat(bots): emergency stop button and /stop slash command in group chat (#1012)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-30 00:02:16 +03:00
1 parent bd4a73dd48
commit 97b6641d84
4 files changed
+135 -14

No files matched your search

@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -137,6 +138,18 @@ fun GroupChatScreen(
},
navigationIcon = NavIcon.Back(onBack),
actions = {
if (state.activeSpeaker != null) {
IconButton(
onClick = { viewModel.stopGeneration() },
modifier = Modifier.testTag("group_chat_stop_button"),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.group_chat_action_stop),
tint = MaterialTheme.colorScheme.error,
)
}
}
IconButton(
onClick = { showSettingsDialog = true },
modifier = Modifier.testTag("group_chat_settings_button"),
@@ -293,20 +306,41 @@ fun GroupChatScreen(
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = handleSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
if (state.activeSpeaker != null) {
IconButton(
onClick = { viewModel.stopGeneration() },
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_stop_button"),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.group_chat_action_stop),
modifier = Modifier.size(18.dp),
)
}
} else {
IconButton(
onClick = handleSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -56,6 +56,7 @@ private const val LEASE_STEP_TTL_MS = 45_000L
private const val TURN_TIMEOUT_MS = 45_000L
private const val HISTORY_LIMIT = 10
private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
private const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
class GroupChatViewModel(
private var groupName: String = "",
@@ -767,11 +768,65 @@ class GroupChatViewModel(
}
}
fun stopGeneration() {
Log.i("GroupChatViewModel", "stopGeneration requested by user")
val interruptedEpoch = UUID.randomUUID().toString()
activeEpoch = interruptedEpoch
// Send interrupt signal to any actively streaming or in-flight sessions
val activeSessions = activeStreamMsgId.keys.toList() + inFlightTurns.keys.toList()
for (sid in activeSessions.distinct()) {
try {
HermesWsClient.send(
WsMethods.SESSION_INTERRUPT,
mapOf("session_id" to sid),
)
} catch (e: Exception) {
Log.w("GroupChatViewModel", "Failed to send interrupt for session $sid: ${e.message}")
}
}
// Clean up transient in-flight tracking
inFlightTurns.values.forEach { it.complete("") }
inFlightTurns.clear()
activeStreamMsgId.clear()
sessionToBot.clear()
_uiState.update { state ->
// Remove any pending streaming placeholder messages
val cleanedMessages = state.messages.filter { !it.isStreaming }
val stopMessage =
GroupChatMessage(
id = UUID.randomUUID().toString(),
senderName = "system",
senderDisplayName = "System",
isUser = false,
isSystem = true,
text = STOPPED_SYSTEM_TEXT,
)
state.copy(
messages = cleanedMessages + stopMessage,
activeSpeaker = null,
)
}
persistSyncSnapshot(_uiState.value.messages, extendLease = false)
}
private fun isStopCommand(text: String): Boolean {
val normalized = text.trim().lowercase()
return normalized in setOf("/stop", "/interrupt", "/pause", "/cancel", "stop", "/brake")
}
fun sendMessage(rawText: String) {
val text = rawText.trim()
Log.d("GroupChatViewModel", "sendMessage called with: '$text'")
if (text.isBlank()) return
if (isStopCommand(text)) {
stopGeneration()
return
}
val members = _uiState.value.members
if (members.isEmpty()) {
Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring")