@@ -68,6 +68,9 @@ import androidx.compose.material.icons.rounded.Summarize
6868import androidx.compose.material.icons.rounded.Terminal
6969import androidx.compose.material.icons.rounded.ViewModule
7070import androidx.compose.material3.CircularProgressIndicator
71+ import androidx.compose.material3.Badge
72+ import androidx.compose.material3.DropdownMenu
73+ import androidx.compose.material3.DropdownMenuItem
7174import androidx.compose.material3.ExperimentalMaterial3Api
7275import androidx.compose.material3.HorizontalDivider
7376import androidx.compose.material3.Icon
@@ -89,6 +92,7 @@ import androidx.compose.runtime.remember
8992import androidx.compose.runtime.rememberCoroutineScope
9093import androidx.compose.runtime.saveable.rememberSaveable
9194import androidx.compose.runtime.setValue
95+ import androidx.compose.runtime.snapshotFlow
9296import androidx.lifecycle.compose.collectAsStateWithLifecycle
9397import kotlinx.coroutines.launch
9498import androidx.compose.ui.Alignment
@@ -103,6 +107,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
103107import androidx.compose.ui.res.stringResource
104108import androidx.compose.ui.text.style.TextOverflow
105109import androidx.compose.ui.unit.dp
110+ import androidx.compose.ui.window.PopupProperties
106111import androidx.core.content.FileProvider
107112import me.rerere.ai.provider.Model
108113import me.rerere.rikkahub.R
@@ -112,6 +117,7 @@ import me.rerere.rikkahub.data.datastore.Settings
112117import me.rerere.rikkahub.data.datastore.ConversationWorkDirBinding
113118import me.rerere.rikkahub.data.datastore.ConversationWorkDirMode
114119import me.rerere.rikkahub.data.datastore.findProvider
120+ import me.rerere.rikkahub.data.datastore.getAssistantById
115121import me.rerere.rikkahub.data.datastore.getCurrentAssistant
116122import me.rerere.rikkahub.data.datastore.getCurrentChatModel
117123import me.rerere.rikkahub.data.datastore.getEffectiveWorkspaceRootTreeUri
@@ -120,6 +126,7 @@ import me.rerere.rikkahub.data.model.Assistant
120126import me.rerere.rikkahub.data.model.Conversation
121127import me.rerere.rikkahub.service.ChatService
122128import me.rerere.rikkahub.ui.components.crop.CropImageScreen
129+ import me.rerere.rikkahub.ui.components.ui.UIAvatar
123130import me.rerere.rikkahub.ui.components.ui.ToastType
124131import me.rerere.rikkahub.ui.components.ui.permission.PermissionCamera
125132import me.rerere.rikkahub.ui.components.ui.permission.PermissionManager
@@ -134,7 +141,9 @@ import me.rerere.rikkahub.utils.createChatFilesByContents
134141import me.rerere.rikkahub.utils.getFileMimeType
135142import me.rerere.rikkahub.utils.getFileNameFromUri
136143import java.io.File
144+ import java.util.Locale
137145import kotlinx.coroutines.Dispatchers
146+ import kotlinx.coroutines.flow.collect
138147import kotlinx.coroutines.withContext
139148import kotlin.uuid.Uuid
140149
@@ -172,6 +181,7 @@ fun MinimalChatInput(
172181 val toaster = LocalToaster .current
173182 val assistant = settings.getCurrentAssistant()
174183 val haptics = rememberPremiumHaptics(enabled = settings.displaySetting.enableUIHaptics)
184+ val defaultAssistantName = stringResource(R .string.assistant_page_default_assistant)
175185 val keyboardController = LocalSoftwareKeyboardController .current
176186 val localSettings = LocalSettings .current
177187
@@ -190,6 +200,30 @@ fun MinimalChatInput(
190200 var showPicker by remember { mutableStateOf(false ) }
191201 var isFocused by remember { mutableStateOf(false ) }
192202
203+ val groupChatTemplateForMentions = remember(
204+ uiMode,
205+ settings.groupChatTemplates,
206+ conversation.assistantId,
207+ ) {
208+ if (uiMode != ChatInputUiMode .GroupChat ) return @remember null
209+ settings.groupChatTemplates.firstOrNull { it.id == conversation.assistantId }
210+ }
211+
212+ val groupChatMentionKeys = remember(
213+ groupChatTemplateForMentions,
214+ settings.assistants,
215+ settings.assistantTags,
216+ defaultAssistantName,
217+ ) {
218+ groupChatTemplateForMentions?.let { template ->
219+ buildGroupChatMentionKeySuggestions(
220+ settings = settings,
221+ template = template,
222+ defaultAssistantName = defaultAssistantName,
223+ )
224+ }.orEmpty()
225+ }
226+
193227 val workDirKey = conversation.id.toString()
194228 val currentWorkDirBinding = settings.conversationWorkDirs[workDirKey]
195229 val effectiveWorkspaceRootTreeUri = settings.getEffectiveWorkspaceRootTreeUri(conversation.id)
@@ -332,6 +366,125 @@ fun MinimalChatInput(
332366 bottom = 12 .dp
333367 )
334368 )
369+
370+ val enableMentionSuggestions = groupChatMentionKeys.isNotEmpty()
371+ if (enableMentionSuggestions) {
372+ val validMentionKeySet = remember(groupChatMentionKeys) {
373+ groupChatMentionKeys.map { it.normalizedKey }.toSet()
374+ }
375+ val text = state.textContent.text.toString()
376+ val selection = state.textContent.selection
377+ val cursor = kotlin.math.max(selection.start, selection.end).coerceIn(0 , text.length)
378+
379+ LaunchedEffect (enableMentionSuggestions, validMentionKeySet) {
380+ if (! enableMentionSuggestions) return @LaunchedEffect
381+ var previousText = state.textContent.text.toString()
382+ var previousSelection = state.textContent.selection
383+ var skipNext = false
384+
385+ snapshotFlow { state.textContent.text.toString() to state.textContent.selection }
386+ .collect { (currentText, currentSelection) ->
387+ if (skipNext) {
388+ skipNext = false
389+ previousText = currentText
390+ previousSelection = currentSelection
391+ return @collect
392+ }
393+
394+ val previousCursor =
395+ kotlin.math.max(previousSelection.start, previousSelection.end)
396+ .coerceIn(0 , previousText.length)
397+ val currentCursor =
398+ kotlin.math.max(currentSelection.start, currentSelection.end)
399+ .coerceIn(0 , currentText.length)
400+
401+ val isBackspace = previousSelection.start == previousSelection.end &&
402+ currentSelection.start == currentSelection.end &&
403+ previousText.length == currentText.length + 1 &&
404+ currentCursor == previousCursor - 1
405+
406+ if (isBackspace) {
407+ val mentionStart = findMentionTokenStartForAtomicBackspace(
408+ text = previousText,
409+ cursor = previousCursor,
410+ validMentionKeys = validMentionKeySet,
411+ )
412+ if (mentionStart != null && mentionStart < currentCursor) {
413+ skipNext = true
414+ haptics.perform(HapticPattern .Pop )
415+ state.replaceText(mentionStart, currentCursor, " " )
416+ }
417+ }
418+
419+ previousText = currentText
420+ previousSelection = currentSelection
421+ }
422+ }
423+
424+ val activeMention = remember(text, cursor, selection) {
425+ if (selection.start != selection.end) return @remember null
426+ findActiveMentionContext(text = text, cursor = cursor)
427+ }
428+
429+ val queryNormalized = remember(activeMention) {
430+ activeMention?.query?.lowercase(Locale .ROOT ).orEmpty()
431+ }
432+
433+ val filteredSuggestions = remember(groupChatMentionKeys, queryNormalized) {
434+ filterGroupChatMentionSuggestions(
435+ suggestions = groupChatMentionKeys,
436+ queryNormalized = queryNormalized,
437+ )
438+ }
439+
440+ var menuExpanded by remember { mutableStateOf(false ) }
441+ LaunchedEffect (activeMention, isFocused, filteredSuggestions) {
442+ menuExpanded = isFocused && activeMention != null && filteredSuggestions.isNotEmpty()
443+ }
444+
445+ DropdownMenu (
446+ expanded = menuExpanded,
447+ onDismissRequest = { menuExpanded = false },
448+ modifier = Modifier .fillMaxWidth(),
449+ properties = PopupProperties (focusable = false ),
450+ ) {
451+ filteredSuggestions.take(8 ).forEach { suggestion ->
452+ val suggestionAssistant = suggestion.sampleAssistantId
453+ ?.let (settings::getAssistantById)
454+ DropdownMenuItem (
455+ leadingIcon = {
456+ suggestionAssistant?.let { assistant ->
457+ UIAvatar (
458+ name = assistant.name.ifBlank { suggestion.displayName },
459+ value = assistant.avatar,
460+ modifier = Modifier .size(28 .dp),
461+ loading = false ,
462+ )
463+ }
464+ },
465+ text = { Text (text = " @${suggestion.displayName} " ) },
466+ trailingIcon = {
467+ if (suggestion.seatCount > 1 ) {
468+ Badge { Text (suggestion.seatCount.toString()) }
469+ }
470+ },
471+ onClick = {
472+ val context = activeMention ? : return @DropdownMenuItem
473+ val needsTrailingSpace =
474+ cursor >= text.length || ! text[cursor].isWhitespace()
475+ val replacement = buildString {
476+ append(' @' )
477+ append(suggestion.displayName)
478+ if (needsTrailingSpace) append(' ' )
479+ }
480+ haptics.perform(HapticPattern .Pop )
481+ state.replaceText(context.atIndex, context.cursor, replacement)
482+ menuExpanded = false
483+ },
484+ )
485+ }
486+ }
487+ }
335488
336489 // Action button - bottom-right, 4dp padding ("4dp all around")
337490 Box (
@@ -852,7 +1005,7 @@ private fun MinimalPickerContent(
8521005 }
8531006 )
8541007
855- if (mcpServers.isNotEmpty()) {
1008+ if (uiMode == ChatInputUiMode . Normal && mcpServers.isNotEmpty()) {
8561009 MinimalPickerItem (
8571010 icon = {
8581011 if (mcpLoading) {
0 commit comments