diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt
index 6bfd25e0..34661c08 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt
@@ -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),
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt
index fa4d121f..67df208c 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt
@@ -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")
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f51bb5cd..d086f4ea 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1264,4 +1264,6 @@
Custom rules, tone, or context for this group (optional)…
Custom guidelines for all bots in this room
Settings
+ Discussion stopped
+ Stop generation
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt
index c223f648..a1ca7537 100644
--- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt
+++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt
@@ -486,4 +486,34 @@ class GroupChatViewModelTest {
assertEquals(1, finalMsg.toolCalls.size)
assertFalse(finalMsg.toolCalls.first().isRunning)
}
+
+ @Test
+ fun testStopCommand_stopsGenerationAndAppendsSystemBanner() =
+ runTest(testDispatcher) {
+ val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher)
+ testScheduler.runCurrent()
+
+ // Trigger a message
+ viewModel.sendMessage("@scout long task")
+ testScheduler.runCurrent()
+
+ // Emit a streaming fragment
+ eventsFlow.emit(
+ WsEvent.MessageToken(
+ token = "Thinking about...",
+ sessionId = "session-scout-1",
+ ),
+ )
+ testScheduler.runCurrent()
+
+ // Execute /stop command
+ viewModel.sendMessage("/stop")
+ testScheduler.runCurrent()
+
+ val state = viewModel.uiState.value
+ assertNull(state.activeSpeaker)
+ val lastMsg = state.messages.last()
+ assertTrue(lastMsg.isSystem)
+ assertEquals("Discussion stopped", lastMsg.text)
+ }
}