Merge pull request #57 from Hy4ri/fix/issue-44-chat-search

Fix chat search breaking vertical scrolling and layout (Issue #44)
This commit is contained in:
M57 authored and GitHub committed 2026-06-19 19:41:20 +03:00
commit 379c9e07e7
3 files changed
+224 -83

No files matched your search

@@ -72,6 +72,7 @@ fun ChatBubble(
message: ChatMessage, message: ChatMessage,
isDarkTheme: Boolean, isDarkTheme: Boolean,
searchQuery: String = "", searchQuery: String = "",
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp val screenWidth = LocalConfiguration.current.screenWidthDp.dp
@@ -86,10 +87,28 @@ fun ChatBubble(
), ),
) { ) {
when (message.role) { when (message.role) {
MessageRole.USER -> UserBubble(message, maxBubbleWidth, searchQuery, modifier) MessageRole.USER -> {
MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, searchQuery, modifier) UserBubble(message, maxBubbleWidth, searchQuery, isCurrentMatch, modifier)
MessageRole.SYSTEM -> SystemBubble(message, modifier) }
MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier)
MessageRole.ASSISTANT -> {
AssistantBubble(
message,
maxBubbleWidth,
isDarkTheme,
searchQuery,
isCurrentMatch,
modifier,
)
}
MessageRole.SYSTEM -> {
SystemBubble(message, modifier)
}
MessageRole.TOOL -> {
ToolBubble(message, isDarkTheme, modifier)
}
} }
} }
} }
@@ -99,14 +118,15 @@ private fun UserBubble(
message: ChatMessage, message: ChatMessage,
maxWidth: androidx.compose.ui.unit.Dp, maxWidth: androidx.compose.ui.unit.Dp,
searchQuery: String = "", searchQuery: String = "",
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val highlightedText = val highlightedText =
remember(message.content, searchQuery) { remember(message.content, searchQuery, isCurrentMatch) {
if (searchQuery.isNotBlank()) { if (searchQuery.isNotBlank()) {
buildHighlightedString(message.content, searchQuery) buildHighlightedString(message.content, searchQuery, isCurrentMatch)
} else { } else {
AnnotatedString(message.content) AnnotatedString(message.content)
} }
@@ -176,6 +196,7 @@ private fun AssistantBubble(
maxWidth: androidx.compose.ui.unit.Dp, maxWidth: androidx.compose.ui.unit.Dp,
isDarkTheme: Boolean, isDarkTheme: Boolean,
searchQuery: String = "", searchQuery: String = "",
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
@@ -222,6 +243,7 @@ private fun AssistantBubble(
text = message.content, text = message.content,
textColor = textColor, textColor = textColor,
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
) )
} }
if (!message.isStreaming) { if (!message.isStreaming) {
@@ -353,13 +375,14 @@ private fun RichText(
text: String, text: String,
textColor: Color, textColor: Color,
searchQuery: String = "", searchQuery: String = "",
isCurrentMatch: Boolean = false,
) { ) {
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 searchHighlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
val annotated = val annotated =
remember(text, searchQuery) { remember(text, searchQuery, isCurrentMatch) {
buildAnnotatedString { buildAnnotatedString {
var i = 0 var i = 0
val src = text val src = text
@@ -477,8 +500,9 @@ private fun formatTimestamp(timestamp: Long): String {
private fun buildHighlightedString( private fun buildHighlightedString(
text: String, text: String,
query: String, query: String,
isCurrentMatch: Boolean = false,
): AnnotatedString { ): AnnotatedString {
val highlightColor = Color(0xFFFFF176).copy(alpha = 0.9f) val highlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
return buildAnnotatedString { return buildAnnotatedString {
var i = 0 var i = 0
while (i < text.length) { while (i < text.length) {
@@ -14,6 +14,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -31,9 +32,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons 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
@@ -78,6 +82,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -165,55 +170,41 @@ 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 // Scroll to current search match
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex) { LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
if (state.isSearchActive && state.currentSearchMatchIndex >= 0 && filteredMessages.isNotEmpty()) { if (state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.messages.isNotEmpty()
) {
val targetIndex = state.searchMatchIndices[state.currentSearchMatchIndex]
listState.animateScrollToItem( listState.animateScrollToItem(
state.currentSearchMatchIndex.coerceIn(0, filteredMessages.lastIndex), targetIndex.coerceIn(0, state.messages.lastIndex),
) )
} }
} }
HermesScaffold( HermesScaffold(
modifier = modifier, modifier = modifier,
pinTopBar = true,
title = { title = {
if (state.isSearchActive) { Row(verticalAlignment = Alignment.CenterVertically) {
SearchBarRow( Text(
searchQuery = state.searchQuery, text = "Hermes",
onQueryChange = { viewModel.setSearchQuery(it) }, style =
searchMatchCount = state.searchMatchIndices.size, MaterialTheme.typography.titleLarge.copy(
currentMatchIndex = state.currentSearchMatchIndex, fontWeight = FontWeight.Bold,
onNavigateUp = { viewModel.navigateSearchMatch(-1) }, ),
onNavigateDown = { viewModel.navigateSearchMatch(1) }, )
onClose = { viewModel.clearSearch() }, Spacer(modifier = Modifier.width(8.dp))
// 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,
@@ -330,24 +321,46 @@ fun ChatScreen(
.background(backgroundGradient) .background(backgroundGradient)
.imePadding(), .imePadding(),
) { ) {
androidx.compose.animation.AnimatedVisibility(
visible = state.isSearchActive,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
),
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
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() },
)
}
}
}
Box( Box(
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
if (filteredMessages.isEmpty() && !state.isLoading) { if (state.messages.isEmpty() && !state.isLoading) {
if (state.isSearchActive && state.searchQuery.isNotBlank()) { EmptyState(
EmptyState( title = "Ready to chat",
title = "No matches", subtitle = "Send a message to start a conversation",
subtitle = "No messages match \"${state.searchQuery}\"", )
)
} else {
EmptyState(
title = "Ready to chat",
subtitle = "Send a message to start a conversation",
)
}
} }
LazyColumn( LazyColumn(
@@ -355,14 +368,21 @@ fun ChatScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
items( itemsIndexed(
items = filteredMessages, items = state.messages,
key = { it.id }, key = { _, message -> message.id },
) { message -> ) { index, message ->
val isCurrentMatch =
state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.searchMatchIndices[state.currentSearchMatchIndex] == index
ChatBubble( ChatBubble(
message = message, message = message,
isDarkTheme = isDark, isDarkTheme = isDark,
searchQuery = if (state.isSearchActive) state.searchQuery else "", searchQuery = if (state.isSearchActive) state.searchQuery else "",
isCurrentMatch = isCurrentMatch,
) )
} }
@@ -698,6 +718,72 @@ private fun ClarifyDialog(
) )
} }
@Composable
private fun CompactSearchInput(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false,
) {
val textColor = MaterialTheme.colorScheme.onSurface
val errorColor = MaterialTheme.colorScheme.error
val outlineColor = if (isError) errorColor else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier =
modifier
.height(40.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(20.dp),
).border(
width = 1.dp,
color = outlineColor,
shape = RoundedCornerShape(20.dp),
).padding(horizontal = 12.dp),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Box(modifier = Modifier.weight(1f)) {
if (value.isEmpty()) {
Text(
text = "Search messages…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
if (value.isNotEmpty()) {
IconButton(
onClick = { onValueChange("") },
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Clear",
modifier = Modifier.size(16.dp),
)
}
}
}
},
)
}
@Composable @Composable
private fun SearchBarRow( private fun SearchBarRow(
searchQuery: String, searchQuery: String,
@@ -708,45 +794,70 @@ private fun SearchBarRow(
onNavigateDown: () -> Unit, onNavigateDown: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val isError = searchQuery.isNotEmpty() && searchMatchCount == 0
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedTextField( CompactSearchInput(
value = searchQuery, value = searchQuery,
onValueChange = onQueryChange, onValueChange = onQueryChange,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { Text("Search messages…") }, isError = isError,
singleLine = true,
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Filled.Close, contentDescription = "Clear")
}
}
},
) )
if (searchMatchCount > 0 && searchQuery.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
val countText =
if (searchMatchCount > 0) {
"${currentMatchIndex + 1}/$searchMatchCount"
} else {
"0/0"
}
Text( Text(
text = "${currentMatchIndex + 1}/$searchMatchCount", text = countText,
style = MaterialTheme.typography.bodySmall, style =
MaterialTheme.typography.bodySmall.copy(
color =
if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
modifier = Modifier.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp),
) )
} }
IconButton(onClick = onNavigateUp) { IconButton(
onClick = onNavigateUp,
enabled = searchMatchCount > 0,
modifier = Modifier.size(36.dp),
) {
Icon( Icon(
Icons.Filled.KeyboardArrowUp, Icons.Filled.KeyboardArrowUp,
contentDescription = "Previous match", contentDescription = "Previous match",
modifier = Modifier.size(20.dp),
) )
} }
IconButton(onClick = onNavigateDown) { IconButton(
onClick = onNavigateDown,
enabled = searchMatchCount > 0,
modifier = Modifier.size(36.dp),
) {
Icon( Icon(
Icons.Filled.KeyboardArrowDown, Icons.Filled.KeyboardArrowDown,
contentDescription = "Next match", contentDescription = "Next match",
modifier = Modifier.size(20.dp),
) )
} }
IconButton(onClick = onClose) { IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Close search") Icon(
Icons.Filled.Close,
contentDescription = "Close search",
modifier = Modifier.size(20.dp),
)
} }
} }
} }
@@ -43,11 +43,17 @@ fun HermesScaffold(
isRefreshing: Boolean = false, isRefreshing: Boolean = false,
onBack: (() -> Unit)? = null, onBack: (() -> Unit)? = null,
showBack: Boolean = false, showBack: Boolean = false,
pinTopBar: Boolean = false,
snackbarHost: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {},
actions: @Composable () -> Unit = {}, actions: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
) { ) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollBehavior =
if (pinTopBar) {
TopAppBarDefaults.pinnedScrollBehavior()
} else {
TopAppBarDefaults.enterAlwaysScrollBehavior()
}
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),