feat(bots): add horizontal @ mention suggestion chip bar in group chat (#1048)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 14:38:09 +03:00
1 parent cd8c27e62b
commit 78e104f432
2 files changed
+211 -20

No files matched your search

@@ -2,6 +2,11 @@ package com.m57.hermescontrol.ui.bots.group
import android.content.ClipData import android.content.ClipData
import android.text.format.DateFormat import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -18,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -31,6 +37,7 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy 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.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
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
@@ -44,6 +51,7 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -65,17 +73,21 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
@@ -85,6 +97,7 @@ import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -283,6 +296,111 @@ fun GroupChatScreen(
} }
} }
// ── MENTION SUGGESTION CHIP BAR (Horizontal scrollable chips) ──
val mentionQuery =
remember(inputFieldValue.text, inputFieldValue.selection.end) {
ChatInputPolicy.extractMentionQuery(
inputFieldValue.text,
inputFieldValue.selection.end,
)
}
val groupBots =
remember(state.members) {
state.members.distinctBy { it.name.lowercase() }
}
val showAllOption =
remember(mentionQuery, groupBots) {
if (mentionQuery == null || groupBots.isEmpty()) {
false
} else {
"all".startsWith(mentionQuery, ignoreCase = true) ||
"everyone".startsWith(mentionQuery, ignoreCase = true)
}
}
val filteredBots =
remember(mentionQuery, groupBots) {
if (mentionQuery == null) {
emptyList()
} else {
groupBots.filter { bot ->
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
}
}
}
AnimatedVisibility(
visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
LazyRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp)
.testTag("group_chat_mention_row"),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showAllOption) {
item(key = "mention_all") {
SuggestionChip(
onClick = {
inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, "all")
},
label = {
Text(
text = "@all",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
},
icon = {
Icon(
imageVector = Icons.Filled.Groups,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.testTag("mention_chip_all"),
)
}
}
items(filteredBots, key = { it.name }) { bot ->
SuggestionChip(
onClick = {
inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, bot.name)
},
label = {
Text(
text = "@${bot.name}",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelMedium,
)
},
icon = {
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 18.dp,
showPresence = false,
)
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.testTag("mention_chip_${bot.name}"),
)
}
}
}
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
Surface( Surface(
modifier = modifier =
@@ -309,7 +427,23 @@ fun GroupChatScreen(
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(modifier = Modifier.weight(1f)) { val isInputRtl = remember(inputFieldValue.text) { BidiUtils.isRtlText(inputFieldValue.text) }
val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) {
if (inputFieldValue.text.isBlank()) {
ambientLayoutDirection
} else if (isInputRtl) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = if (isInputRtl) Alignment.CenterEnd else Alignment.CenterStart,
) {
if (inputFieldValue.text.isEmpty()) { if (inputFieldValue.text.isEmpty()) {
Text( Text(
text = text =
@@ -319,27 +453,32 @@ fun GroupChatScreen(
), ),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
modifier = Modifier.fillMaxWidth(),
)
}
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = { inputFieldValue = it },
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 24.dp, max = 120.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("group_chat_input"),
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { handleSend() }),
) )
} }
BasicTextField(
value = inputFieldValue,
onValueChange = { inputFieldValue = it },
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 24.dp, max = 120.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("group_chat_input"),
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { handleSend() }),
)
} }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -68,4 +68,56 @@ class GroupChatMentionsTest {
assertEquals(1, responders.size) assertEquals(1, responders.size)
assertEquals("coder", responders.first().name) assertEquals("coder", responders.first().name)
} }
@Test
fun groupChatMentionAutocomplete_endToEndIntegration() {
// 1. User types "@cod"
val input =
androidx.compose.ui.text.input
.TextFieldValue(
"hey @cod",
androidx.compose.ui.text
.TextRange(8),
)
val query =
com.m57.hermescontrol.ui.chat.ChatInputPolicy
.extractMentionQuery(input.text, input.selection.end)
assertEquals("cod", query)
// 2. Candidate filtering:
// Query "@c" matches both "coder" (starts with c) and "Scout Bot" (contains c)
val broadMatches =
members.filter {
it.name.startsWith("c", ignoreCase = true) || it.effectiveTitle.contains("c", ignoreCase = true)
}
assertEquals(2, broadMatches.size)
// Narrower query "@cod" matches only "coder"
val matchingBots =
members.filter {
it.name.startsWith(query!!, ignoreCase = true) || it.effectiveTitle.contains(query, ignoreCase = true)
}
assertEquals(1, matchingBots.size)
assertEquals("coder", matchingBots.first().name)
// 3. User taps chip for coder
val completed =
com.m57.hermescontrol.ui.chat.ChatInputPolicy
.applyMention(input, matchingBots.first().name)
assertEquals("hey @coder ", completed.text)
assertEquals(11, completed.selection.end)
// 4. Mention query is now null after space
val nextQuery =
com.m57.hermescontrol.ui.chat.ChatInputPolicy.extractMentionQuery(
completed.text,
completed.selection.end,
)
org.junit.Assert.assertNull(nextQuery)
// 5. Group chat mentions resolver picks up the mentioned bot
val responders = GroupChatMentions.resolveResponders(completed.text, members)
assertEquals(1, responders.size)
assertEquals("coder", responders.first().name)
}
} }