From 59e0d4f8176dd69e4edd50c4d035f634518f8988 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:35:07 +0300 Subject: [PATCH] feat: search within chat history (Issue #31) (#39) - 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 --- .../m57/hermescontrol/ui/chat/ChatBubble.kt | 63 +++++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 148 +++++++++++++++--- .../hermescontrol/ui/chat/ChatViewModel.kt | 61 ++++++++ 3 files changed, 246 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 27ddca49..c0fc9916 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -71,6 +71,7 @@ import java.util.Locale fun ChatBubble( message: ChatMessage, isDarkTheme: Boolean, + searchQuery: String = "", modifier: Modifier = Modifier, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -85,8 +86,8 @@ fun ChatBubble( ), ) { when (message.role) { - MessageRole.USER -> UserBubble(message, maxBubbleWidth, modifier) - MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, modifier) + MessageRole.USER -> UserBubble(message, maxBubbleWidth, searchQuery, modifier) + MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, searchQuery, modifier) MessageRole.SYSTEM -> SystemBubble(message, modifier) MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier) } @@ -97,9 +98,19 @@ fun ChatBubble( private fun UserBubble( message: ChatMessage, maxWidth: androidx.compose.ui.unit.Dp, + searchQuery: String = "", modifier: Modifier = Modifier, ) { val clipboardManager = LocalClipboardManager.current + + val highlightedText = + remember(message.content, searchQuery) { + if (searchQuery.isNotBlank()) { + buildHighlightedString(message.content, searchQuery) + } else { + AnnotatedString(message.content) + } + } Box( modifier = modifier @@ -140,7 +151,7 @@ private fun UserBubble( Column(modifier = Modifier.padding(12.dp)) { SelectionContainer { Text( - text = message.content, + text = highlightedText, color = Color.White, style = MaterialTheme.typography.bodyMedium, ) @@ -164,6 +175,7 @@ private fun AssistantBubble( message: ChatMessage, maxWidth: androidx.compose.ui.unit.Dp, isDarkTheme: Boolean, + searchQuery: String = "", modifier: Modifier = Modifier, ) { val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight @@ -210,6 +222,7 @@ private fun AssistantBubble( RichText( text = message.content, textColor = textColor, + searchQuery = searchQuery, ) } if (!message.isStreaming) { @@ -340,12 +353,14 @@ private fun ToolBubble( private fun RichText( text: String, textColor: Color, + searchQuery: String = "", ) { val uriHandler = LocalUriHandler.current val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") } val linkColor = MaterialTheme.colorScheme.primary + val searchHighlightColor = Color(0xFFFFF176).copy(alpha = 0.9f) val annotated = - remember(text) { + remember(text, searchQuery) { buildAnnotatedString { var i = 0 val src = text @@ -422,6 +437,15 @@ private fun RichText( 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 -> { append(src[i]) i++ @@ -445,3 +469,34 @@ private fun formatTimestamp(timestamp: Long): String { val sdf = SimpleDateFormat("HH:mm", Locale.getDefault()) 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 + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index ab7e1e6e..850a3191 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -38,7 +38,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add 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.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.Stop 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( modifier = modifier, title = { - 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), + if (state.isSearchActive) { + SearchBarRow( + searchQuery = state.searchQuery, + onQueryChange = { viewModel.setSearchQuery(it) }, + searchMatchCount = state.searchMatchIndices.size, + currentMatchIndex = state.currentSearchMatchIndex, + onNavigateUp = { viewModel.navigateSearchMatch(-1) }, + onNavigateDown = { viewModel.navigateSearchMatch(1) }, + onClose = { viewModel.clearSearch() }, ) + } 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, @@ -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 IconButton(onClick = onNavigateToSettings) { Icon( @@ -292,11 +335,18 @@ fun ChatScreen( .weight(1f) .fillMaxWidth(), ) { - if (state.messages.isEmpty() && !state.isLoading) { - EmptyState( - title = "Ready to chat", - subtitle = "Send a message to start a conversation", - ) + if (filteredMessages.isEmpty() && !state.isLoading) { + if (state.isSearchActive && state.searchQuery.isNotBlank()) { + EmptyState( + title = "No matches", + subtitle = "No messages match \"${state.searchQuery}\"", + ) + } else { + EmptyState( + title = "Ready to chat", + subtitle = "Send a message to start a conversation", + ) + } } LazyColumn( @@ -305,12 +355,13 @@ fun ChatScreen( contentPadding = PaddingValues(vertical = 8.dp), ) { items( - items = state.messages, + items = filteredMessages, key = { it.id }, ) { message -> ChatBubble( message = message, 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") + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index e465c68c..3525dd25 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -33,6 +33,11 @@ data class ChatUiState( val errorMessage: String? = null, val clarifyRequest: ClarifyUi? = null, val showSessionPicker: Boolean = false, + // Search state + val isSearchActive: Boolean = false, + val searchQuery: String = "", + val searchMatchIndices: List = emptyList(), + val currentSearchMatchIndex: Int = -1, ) 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() { super.onCleared() viewModelScope.launch(Dispatchers.IO) {