Fix chat search breaking vertical scrolling and layout (Issue #44)

This commit is contained in:
Hy4ri committed 2026-06-19 18:59:43 +03:00
1 parent 055b3381b1
commit f7132a76cd
2 files changed
+83 -41

No files matched your search

@@ -72,6 +72,7 @@ fun ChatBubble(
message: ChatMessage,
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
@@ -86,10 +87,28 @@ fun ChatBubble(
),
) {
when (message.role) {
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)
MessageRole.USER -> {
UserBubble(message, maxBubbleWidth, searchQuery, isCurrentMatch, 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,
maxWidth: androidx.compose.ui.unit.Dp,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier,
) {
val clipboardManager = LocalClipboardManager.current
val highlightedText =
remember(message.content, searchQuery) {
remember(message.content, searchQuery, isCurrentMatch) {
if (searchQuery.isNotBlank()) {
buildHighlightedString(message.content, searchQuery)
buildHighlightedString(message.content, searchQuery, isCurrentMatch)
} else {
AnnotatedString(message.content)
}
@@ -176,6 +196,7 @@ private fun AssistantBubble(
maxWidth: androidx.compose.ui.unit.Dp,
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier,
) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
@@ -222,6 +243,7 @@ private fun AssistantBubble(
text = message.content,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
)
}
if (!message.isStreaming) {
@@ -353,13 +375,14 @@ private fun RichText(
text: String,
textColor: Color,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) {
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 searchHighlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
val annotated =
remember(text, searchQuery) {
remember(text, searchQuery, isCurrentMatch) {
buildAnnotatedString {
var i = 0
val src = text
@@ -477,8 +500,9 @@ private fun formatTimestamp(timestamp: Long): String {
private fun buildHighlightedString(
text: String,
query: String,
isCurrentMatch: Boolean = false,
): AnnotatedString {
val highlightColor = Color(0xFFFFF176).copy(alpha = 0.9f)
val highlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
return buildAnnotatedString {
var i = 0
while (i < text.length) {
@@ -31,9 +31,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
@@ -78,6 +80,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
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.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -165,19 +168,16 @@ 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()) {
LaunchedEffect(state.isSearchActive, state.currentSearchMatchIndex, state.searchMatchIndices) {
if (state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.messages.isNotEmpty()
) {
val targetIndex = state.searchMatchIndices[state.currentSearchMatchIndex]
listState.animateScrollToItem(
state.currentSearchMatchIndex.coerceIn(0, filteredMessages.lastIndex),
targetIndex.coerceIn(0, state.messages.lastIndex),
)
}
}
@@ -336,18 +336,11 @@ fun ChatScreen(
.weight(1f)
.fillMaxWidth(),
) {
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",
)
}
if (state.messages.isEmpty() && !state.isLoading) {
EmptyState(
title = "Ready to chat",
subtitle = "Send a message to start a conversation",
)
}
LazyColumn(
@@ -355,14 +348,21 @@ fun ChatScreen(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(
items = filteredMessages,
key = { it.id },
) { message ->
itemsIndexed(
items = state.messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
state.isSearchActive &&
state.currentSearchMatchIndex >= 0 &&
state.currentSearchMatchIndex < state.searchMatchIndices.size &&
state.searchMatchIndices[state.currentSearchMatchIndex] == index
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (state.isSearchActive) state.searchQuery else "",
isCurrentMatch = isCurrentMatch,
)
}
@@ -708,6 +708,8 @@ private fun SearchBarRow(
onNavigateDown: () -> Unit,
onClose: () -> Unit,
) {
val isError = searchQuery.isNotEmpty() && searchMatchCount == 0
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -718,6 +720,8 @@ private fun SearchBarRow(
modifier = Modifier.weight(1f),
placeholder = { Text("Search messages…") },
singleLine = true,
isError = isError,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
@@ -726,20 +730,34 @@ private fun SearchBarRow(
}
},
)
if (searchMatchCount > 0 && searchQuery.isNotEmpty()) {
if (searchQuery.isNotEmpty()) {
val countText =
if (searchMatchCount > 0) {
"${currentMatchIndex + 1}/$searchMatchCount"
} else {
"0/0"
}
Text(
text = "${currentMatchIndex + 1}/$searchMatchCount",
style = MaterialTheme.typography.bodySmall,
text = countText,
style =
MaterialTheme.typography.bodySmall.copy(
color =
if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(onClick = onNavigateUp) {
IconButton(onClick = onNavigateUp, enabled = searchMatchCount > 0) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = "Previous match",
)
}
IconButton(onClick = onNavigateDown) {
IconButton(onClick = onNavigateDown, enabled = searchMatchCount > 0) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = "Next match",