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