feat(chat): add @bot mention autocomplete to ChatComposer (#980)

This commit is contained in:
M57 authored and GitHub committed 2026-08-27 01:51:10 +03:00
1 parent 4e666b49a8
commit b422ead921
5 files changed
+162

No files matched your search

@@ -56,4 +56,48 @@ object ChatInputPolicy {
commands: List<String>,
usageCounts: Map<String, Int>,
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
/**
* Extracts the active mention query (without the '@') if the cursor is currently
* positioned within an '@' mention token (e.g. "hello @res" -> "res").
* Returns null if not in a mention context.
*/
fun extractMentionQuery(
text: String,
cursorPosition: Int,
): String? {
if (text.isEmpty() || cursorPosition < 0 || cursorPosition > text.length) return null
val prefix = text.substring(0, cursorPosition)
val lastAt = prefix.lastIndexOf('@')
if (lastAt == -1) return null
// Ensure '@' is at start of string or preceded by whitespace
if (lastAt > 0 && !prefix[lastAt - 1].isWhitespace()) return null
val query = prefix.substring(lastAt + 1)
// Mention handle cannot contain whitespace
if (query.any { it.isWhitespace() }) return null
return query
}
/**
* Inserts a selected bot handle into the TextFieldValue at the active '@' mention position.
*/
fun applyMention(
current: TextFieldValue,
botName: String,
): TextFieldValue {
val text = current.text
val cursor = current.selection.end.coerceIn(0, text.length)
val prefix = text.substring(0, cursor)
val lastAt = prefix.lastIndexOf('@')
if (lastAt == -1) return current
val beforeAt = text.substring(0, lastAt)
val afterCursor = text.substring(cursor)
val inserted = "@$botName "
val newText = "$beforeAt$inserted$afterCursor"
val newCursor = beforeAt.length + inserted.length
return TextFieldValue(newText, TextRange(newCursor))
}
}
@@ -767,6 +767,7 @@ fun ChatScreen(
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
commandCatalog = state.commandCatalog,
availableBots = state.availableBots,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
onCameraTap = {
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.parseContextBreakdown
import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.remote.ApiClient
@@ -304,6 +305,7 @@ data class ChatUiState(
val typingEffectDelayMs: Int = 30,
// Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(),
val availableBots: List<ProfileInfo> = emptyList(),
// Per-command usage counts for the slash-autocomplete ranking (issue
// #865). Empty until the local store loads; commands without recorded
// usage keep their catalog order.
@@ -1894,6 +1896,12 @@ class ChatViewModel(
WsMethods.COMMANDS_CATALOG,
onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) },
)
// Fetch available bot profiles for @ autocomplete
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
if (profilesResult is NetworkResult.Success) {
val profiles = profilesResult.data.profiles.orEmpty()
_uiState.update { it.copy(availableBots = profiles) }
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -60,9 +61,11 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.common.BotAvatar
/**
* The chat input bar with a two-row layout: input+send on top,
@@ -80,6 +83,7 @@ fun ChatInputBar(
commandCatalog: CommandCatalog,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(),
availableBots: List<ProfileInfo> = emptyList(),
onCameraTap: () -> Unit = {},
onImageTap: () -> Unit = {},
onFileTap: () -> Unit = {},
@@ -177,6 +181,91 @@ fun ChatInputBar(
}
}
// Bot mention @ suggestions
val mentionQuery =
remember(inputFieldValue.text, inputFieldValue.selection.end) {
ChatInputPolicy.extractMentionQuery(
inputFieldValue.text,
inputFieldValue.selection.end,
)
}
androidx.compose.animation.AnimatedVisibility(
visible = mentionQuery != null && availableBots.isNotEmpty(),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) {
val filteredBots =
remember(mentionQuery, availableBots) {
if (mentionQuery == null) {
emptyList()
} else {
availableBots.filter { bot ->
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
}
}
}
if (filteredBots.isNotEmpty()) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border =
BorderStroke(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
) {
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredBots, key = { it.name }) { bot ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
onInputChange(
ChatInputPolicy.applyMention(inputFieldValue, bot.name),
)
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BotAvatar(
name = bot.name,
avatar = bot.botMeta()?.avatar,
size = 28.dp,
showPresence = false,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "@${bot.name}",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
if (bot.effectiveTitle != bot.name) {
Text(
text = bot.effectiveTitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
// Attachment preview chips
AnimatedVisibility(
visible = pendingAttachments.isNotEmpty(),