From 78e104f4320d6764da00ccba4513d98d19e7fbef Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:38:09 +0300 Subject: [PATCH] feat(bots): add horizontal @ mention suggestion chip bar in group chat (#1048) --- .../ui/bots/group/GroupChatScreen.kt | 179 ++++++++++++++++-- .../ui/bots/group/GroupChatMentionsTest.kt | 52 +++++ 2 files changed, 211 insertions(+), 20 deletions(-) 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 fc22d011..9900a1a0 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 @@ -2,6 +2,11 @@ package com.m57.hermescontrol.ui.bots.group import android.content.ClipData 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.layout.Arrangement 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.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState 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.ContentCopy 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.Settings 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.OutlinedTextField import androidx.compose.material3.Slider +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction 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.unit.Density +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R 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.components.ChatScrollToBottomFab 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.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.util.BidiUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch 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) ── Surface( modifier = @@ -309,7 +427,23 @@ fun GroupChatScreen( .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), 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()) { Text( text = @@ -319,27 +453,32 @@ fun GroupChatScreen( ), style = MaterialTheme.typography.bodyMedium, 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)) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt index 4171f758..127ca05a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt @@ -68,4 +68,56 @@ class GroupChatMentionsTest { assertEquals(1, responders.size) 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) + } }