- Add search icon toggle in top bar with animated search field - Real-time message filtering on search query match - Match counter with previous/next navigation arrows - Case-insensitive text highlighting in user and assistant bubbles - Dismiss search restores full message list - Empty state for no search results Co-authored-by: ronia <ronia@m57>
This commit is contained in:
1 parent
fb605d1bc0
commit
59e0d4f817
3 files changed
+246
-26
No files matched your search
@@ -71,6 +71,7 @@ import java.util.Locale
|
|||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
searchQuery: String = "",
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
@@ -85,8 +86,8 @@ fun ChatBubble(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
when (message.role) {
|
when (message.role) {
|
||||||
MessageRole.USER -> UserBubble(message, maxBubbleWidth, modifier)
|
MessageRole.USER -> UserBubble(message, maxBubbleWidth, searchQuery, modifier)
|
||||||
MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, modifier)
|
MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, searchQuery, modifier)
|
||||||
MessageRole.SYSTEM -> SystemBubble(message, modifier)
|
MessageRole.SYSTEM -> SystemBubble(message, modifier)
|
||||||
MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier)
|
MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier)
|
||||||
}
|
}
|
||||||
@@ -97,9 +98,19 @@ fun ChatBubble(
|
|||||||
private fun UserBubble(
|
private fun UserBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
maxWidth: androidx.compose.ui.unit.Dp,
|
maxWidth: androidx.compose.ui.unit.Dp,
|
||||||
|
searchQuery: String = "",
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
val highlightedText =
|
||||||
|
remember(message.content, searchQuery) {
|
||||||
|
if (searchQuery.isNotBlank()) {
|
||||||
|
buildHighlightedString(message.content, searchQuery)
|
||||||
|
} else {
|
||||||
|
AnnotatedString(message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -140,7 +151,7 @@ private fun UserBubble(
|
|||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = highlightedText,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
@@ -164,6 +175,7 @@ private fun AssistantBubble(
|
|||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
maxWidth: androidx.compose.ui.unit.Dp,
|
maxWidth: androidx.compose.ui.unit.Dp,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
searchQuery: String = "",
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
||||||
@@ -210,6 +222,7 @@ private fun AssistantBubble(
|
|||||||
RichText(
|
RichText(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
|
searchQuery = searchQuery,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
@@ -340,12 +353,14 @@ private fun ToolBubble(
|
|||||||
private fun RichText(
|
private fun RichText(
|
||||||
text: String,
|
text: String,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
|
searchQuery: String = "",
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") }
|
val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") }
|
||||||
val linkColor = MaterialTheme.colorScheme.primary
|
val linkColor = MaterialTheme.colorScheme.primary
|
||||||
|
val searchHighlightColor = Color(0xFFFFF176).copy(alpha = 0.9f)
|
||||||
val annotated =
|
val annotated =
|
||||||
remember(text) {
|
remember(text, searchQuery) {
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
var i = 0
|
var i = 0
|
||||||
val src = text
|
val src = text
|
||||||
@@ -422,6 +437,15 @@ private fun RichText(
|
|||||||
i = match.range.last + 1
|
i = match.range.last + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search match highlight
|
||||||
|
searchQuery.isNotEmpty() &&
|
||||||
|
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
||||||
|
withStyle(SpanStyle(background = searchHighlightColor)) {
|
||||||
|
append(src.substring(i, i + searchQuery.length))
|
||||||
|
}
|
||||||
|
i += searchQuery.length
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
append(src[i])
|
append(src[i])
|
||||||
i++
|
i++
|
||||||
@@ -445,3 +469,34 @@ private fun formatTimestamp(timestamp: Long): String {
|
|||||||
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
return sdf.format(Date(timestamp))
|
return sdf.format(Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an AnnotatedString with search matches highlighted.
|
||||||
|
*/
|
||||||
|
private fun buildHighlightedString(
|
||||||
|
text: String,
|
||||||
|
query: String,
|
||||||
|
): AnnotatedString {
|
||||||
|
val highlightColor = Color(0xFFFFF176).copy(alpha = 0.9f)
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
val matchEnd = text.indexOf(query, i, ignoreCase = true)
|
||||||
|
if (matchEnd == -1) {
|
||||||
|
// No more matches — append the rest
|
||||||
|
append(text.substring(i))
|
||||||
|
i = text.length
|
||||||
|
} else {
|
||||||
|
// Append text before the match
|
||||||
|
if (matchEnd > i) {
|
||||||
|
append(text.substring(i, matchEnd))
|
||||||
|
}
|
||||||
|
// Append the match highlighted
|
||||||
|
withStyle(SpanStyle(background = highlightColor)) {
|
||||||
|
append(text.substring(matchEnd, matchEnd + query.length))
|
||||||
|
}
|
||||||
|
i = matchEnd + query.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,11 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
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
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -159,26 +163,55 @@ fun ChatScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Search: filter messages to matches only
|
||||||
|
val filteredMessages =
|
||||||
|
if (state.isSearchActive && state.searchQuery.isNotBlank()) {
|
||||||
|
state.messages.filterIndexed { index, _ -> index in state.searchMatchIndices }
|
||||||
|
} else {
|
||||||
|
state.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to current search match
|
||||||
|
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex) {
|
||||||
|
if (state.isSearchActive && state.currentSearchMatchIndex >= 0 && filteredMessages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(
|
||||||
|
state.currentSearchMatchIndex.coerceIn(0, filteredMessages.lastIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
if (state.isSearchActive) {
|
||||||
Text(
|
SearchBarRow(
|
||||||
text = "Hermes",
|
searchQuery = state.searchQuery,
|
||||||
style =
|
onQueryChange = { viewModel.setSearchQuery(it) },
|
||||||
MaterialTheme.typography.titleLarge.copy(
|
searchMatchCount = state.searchMatchIndices.size,
|
||||||
fontWeight = FontWeight.Bold,
|
currentMatchIndex = state.currentSearchMatchIndex,
|
||||||
),
|
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
||||||
)
|
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
onClose = { viewModel.clearSearch() },
|
||||||
// Connection status dot
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (state.isConnected) StatusGreen else StatusRed),
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Hermes",
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.titleLarge.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
// Connection status dot
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (state.isConnected) StatusGreen else StatusRed),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenDrawer = onOpenDrawer,
|
onOpenDrawer = onOpenDrawer,
|
||||||
@@ -269,6 +302,16 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search toggle
|
||||||
|
IconButton(onClick = { viewModel.toggleSearch() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (state.isSearchActive) Icons.Filled.Close else Icons.Filled.Search,
|
||||||
|
contentDescription =
|
||||||
|
if (state.isSearchActive) "Close search" else "Search",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
IconButton(onClick = onNavigateToSettings) {
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -292,11 +335,18 @@ fun ChatScreen(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (state.messages.isEmpty() && !state.isLoading) {
|
if (filteredMessages.isEmpty() && !state.isLoading) {
|
||||||
EmptyState(
|
if (state.isSearchActive && state.searchQuery.isNotBlank()) {
|
||||||
title = "Ready to chat",
|
EmptyState(
|
||||||
subtitle = "Send a message to start a conversation",
|
title = "No matches",
|
||||||
)
|
subtitle = "No messages match \"${state.searchQuery}\"",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EmptyState(
|
||||||
|
title = "Ready to chat",
|
||||||
|
subtitle = "Send a message to start a conversation",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -305,12 +355,13 @@ fun ChatScreen(
|
|||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = state.messages,
|
items = filteredMessages,
|
||||||
key = { it.id },
|
key = { it.id },
|
||||||
) { message ->
|
) { message ->
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isDarkTheme = isDark,
|
isDarkTheme = isDark,
|
||||||
|
searchQuery = if (state.isSearchActive) state.searchQuery else "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,3 +696,56 @@ private fun ClarifyDialog(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchBarRow(
|
||||||
|
searchQuery: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
searchMatchCount: Int,
|
||||||
|
currentMatchIndex: Int,
|
||||||
|
onNavigateUp: () -> Unit,
|
||||||
|
onNavigateDown: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("Search messages…") },
|
||||||
|
singleLine = true,
|
||||||
|
trailingIcon = {
|
||||||
|
if (searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (searchMatchCount > 0 && searchQuery.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "${currentMatchIndex + 1}/$searchMatchCount",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNavigateUp) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.KeyboardArrowUp,
|
||||||
|
contentDescription = "Previous match",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNavigateDown) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.KeyboardArrowDown,
|
||||||
|
contentDescription = "Next match",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Close search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@ data class ChatUiState(
|
|||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val clarifyRequest: ClarifyUi? = null,
|
val clarifyRequest: ClarifyUi? = null,
|
||||||
val showSessionPicker: Boolean = false,
|
val showSessionPicker: Boolean = false,
|
||||||
|
// Search state
|
||||||
|
val isSearchActive: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val searchMatchIndices: List<Int> = emptyList(),
|
||||||
|
val currentSearchMatchIndex: Int = -1,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SessionUi(
|
data class SessionUi(
|
||||||
@@ -671,6 +676,62 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun toggleSearch() {
|
||||||
|
val current = _uiState.value
|
||||||
|
if (current.isSearchActive) {
|
||||||
|
clearSearch()
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(isSearchActive = true, searchQuery = "") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchQuery(query: String) {
|
||||||
|
val messages = _uiState.value.messages
|
||||||
|
val indices =
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
messages.indices.filter { idx ->
|
||||||
|
messages[idx].content.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
searchQuery = query,
|
||||||
|
searchMatchIndices = indices,
|
||||||
|
currentSearchMatchIndex = if (indices.isNotEmpty()) 0 else -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateSearchMatch(direction: Int) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val indices = state.searchMatchIndices
|
||||||
|
if (indices.isEmpty()) return@update state
|
||||||
|
val current = state.currentSearchMatchIndex
|
||||||
|
val newIdx =
|
||||||
|
when (direction) {
|
||||||
|
1 -> if (current >= indices.lastIndex) 0 else current + 1
|
||||||
|
-1 -> if (current <= 0) indices.lastIndex else current - 1
|
||||||
|
else -> current
|
||||||
|
}
|
||||||
|
state.copy(currentSearchMatchIndex = newIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearch() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSearchActive = false,
|
||||||
|
searchQuery = "",
|
||||||
|
searchMatchIndices = emptyList(),
|
||||||
|
currentSearchMatchIndex = -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
Reference in new issue
Block a user