feat(group-chat): add ephemeral visual pass indicator when bot passes turn

- Display a subtle centered system pill '[⏩ {bot} passed]' when a bot passes
- Style with Material icon (Icons.Filled.FastForward) and surface tokens
- Keep pass indicators strictly ephemeral to avoid polluting LLM prompt context
- Exclude from cross-device snapshot sync so it disappears on restart/rehydrate
- Add English and Arabic localized string resources
- Update GroupChatViewModelTest to verify pass indicator presence and LLM filtering

Co-authored-by: Luna <luna.m57@agentmail.to>
This commit is contained in:
M57andLuna committed 2026-09-04 15:20:56 +03:00
1 parent 9288883de4
commit 1126cf1477
6 files changed
+79 -12

No files matched your search

@@ -32,9 +32,13 @@ data class GroupChatMessage(
val isStreaming: Boolean = false,
val thread: String? = null,
val isSystem: Boolean = false,
val isPass: Boolean = false,
val toolCalls: List<GroupChatToolCall> = emptyList(),
)
const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue"
/**
* Pure helper for mention extraction and responder resolution matching Desktop parity.
*/
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
@@ -845,21 +846,53 @@ private fun GroupMessageCard(
}
if (message.isSystem) {
val displayText =
when {
message.isPass -> {
val name = message.senderDisplayName.ifBlank { message.senderName }
stringResource(R.string.group_chat_bot_passed, name)
}
message.text == STOPPED_SYSTEM_TEXT -> {
stringResource(R.string.group_chat_stopped_by_user)
}
message.text == CAPPED_SYSTEM_TEXT -> {
stringResource(R.string.group_chat_capped_limit)
}
else -> {
message.text
}
}
Box(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
) {
Text(
text = message.text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (message.isPass) {
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Text(
text = displayText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (message.isUser) {
@@ -59,8 +59,6 @@ const val WARN_MAX_CONTINUATION_PASSES = 6
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 = "",
@@ -791,6 +789,28 @@ class GroupChatViewModel(
state.copy(messages = updatedList)
}
return replyText.trim()
} else if (replyText != null && isPass(replyText)) {
_uiState.update { state ->
val existing = state.messages.find { it.id == streamMsgId }
val passMsg =
GroupChatMessage(
id = streamMsgId,
senderName = bot.name,
senderDisplayName = bot.effectiveTitle,
isUser = false,
isSystem = true,
isPass = true,
text = "${bot.effectiveTitle} passed",
)
val updatedList =
if (existing != null) {
state.messages.map { if (it.id == streamMsgId) passMsg else it }
} else {
state.messages + passMsg
}
state.copy(messages = updatedList)
}
return null
} else {
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.id != streamMsgId })