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:
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 isStreaming: Boolean = false,
|
||||||
val thread: String? = null,
|
val thread: String? = null,
|
||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
|
val isPass: Boolean = false,
|
||||||
val toolCalls: List<GroupChatToolCall> = emptyList(),
|
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.
|
* 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.Description
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
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.Groups
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -845,21 +846,53 @@ private fun GroupMessageCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.isSystem) {
|
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(
|
Box(
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f),
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = message.text,
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.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) {
|
} 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 LEASE_STEP_TTL_MS = 45_000L
|
||||||
private const val TURN_TIMEOUT_MS = 45_000L
|
private const val TURN_TIMEOUT_MS = 45_000L
|
||||||
private const val HISTORY_LIMIT = 10
|
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(
|
class GroupChatViewModel(
|
||||||
private var groupName: String = "",
|
private var groupName: String = "",
|
||||||
@@ -791,6 +789,28 @@ class GroupChatViewModel(
|
|||||||
state.copy(messages = updatedList)
|
state.copy(messages = updatedList)
|
||||||
}
|
}
|
||||||
return replyText.trim()
|
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 {
|
} else {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
state.copy(messages = state.messages.filter { it.id != streamMsgId })
|
||||||
|
|||||||
@@ -1168,6 +1168,7 @@
|
|||||||
<string name="group_chat_empty_title">لا توجد رسائل بعد</string>
|
<string name="group_chat_empty_title">لا توجد رسائل بعد</string>
|
||||||
<string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string>
|
<string name="group_chat_empty_subtitle">أشر إلى بوت أو استخدم @all لبدء التعاون.</string>
|
||||||
<string name="group_chat_thinking">%1$s يفكّر الآن…</string>
|
<string name="group_chat_thinking">%1$s يفكّر الآن…</string>
|
||||||
|
<string name="group_chat_bot_passed">تخطى %1$s دوره</string>
|
||||||
<string name="group_chat_typing">جارٍ الكتابة…</string>
|
<string name="group_chat_typing">جارٍ الكتابة…</string>
|
||||||
<string name="group_chat_tool_running">جارٍ التشغيل…</string>
|
<string name="group_chat_tool_running">جارٍ التشغيل…</string>
|
||||||
<string name="group_chat_tool_no_output">لا يوجد مخرجات</string>
|
<string name="group_chat_tool_no_output">لا يوجد مخرجات</string>
|
||||||
|
|||||||
@@ -1283,6 +1283,7 @@
|
|||||||
<string name="group_chat_empty_title">No Messages Yet</string>
|
<string name="group_chat_empty_title">No Messages Yet</string>
|
||||||
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
|
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
|
||||||
<string name="group_chat_thinking">%1$s is thinking…</string>
|
<string name="group_chat_thinking">%1$s is thinking…</string>
|
||||||
|
<string name="group_chat_bot_passed">%1$s passed</string>
|
||||||
<string name="group_chat_typing">Typing…</string>
|
<string name="group_chat_typing">Typing…</string>
|
||||||
<string name="group_chat_tool_running">Running…</string>
|
<string name="group_chat_tool_running">Running…</string>
|
||||||
<string name="group_chat_tool_no_output">No output</string>
|
<string name="group_chat_tool_no_output">No output</string>
|
||||||
|
|||||||
@@ -266,9 +266,17 @@ class GroupChatViewModelTest {
|
|||||||
testScheduler.runCurrent()
|
testScheduler.runCurrent()
|
||||||
|
|
||||||
val finalState = viewModel.uiState.value
|
val finalState = viewModel.uiState.value
|
||||||
// Only the user message should remain; (pass) is filtered out
|
// User message plus the visual pass indicator
|
||||||
assertEquals(1, finalState.messages.size)
|
assertEquals(2, finalState.messages.size)
|
||||||
assertTrue(finalState.messages.first().isUser)
|
assertTrue(finalState.messages.first().isUser)
|
||||||
|
val passMsg = finalState.messages.last()
|
||||||
|
assertTrue(passMsg.isSystem)
|
||||||
|
assertTrue(passMsg.isPass)
|
||||||
|
assertEquals("Scout Bot", passMsg.senderDisplayName)
|
||||||
|
assertEquals("Scout Bot passed", passMsg.text)
|
||||||
|
|
||||||
|
// LLM prompt history only considers non-system messages
|
||||||
|
assertEquals(1, finalState.messages.filter { !it.isSystem }.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user