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 <ronia@m57>
This commit is contained in:
M57andronia authored and GitHub committed 2026-06-19 16:35:07 +03:00
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(
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
}
}
}
}
@@ -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")
}
}
}
@@ -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<Int> = 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) {