Fix chat search breaking vertical scrolling and layout (Issue #44)
This commit is contained in:
1 parent
055b3381b1
commit
f7132a76cd
2 files changed
+79
-37
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) {
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ 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.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 +80,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,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
|
// 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,33 +336,33 @@ fun ChatScreen(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (filteredMessages.isEmpty() && !state.isLoading) {
|
if (state.messages.isEmpty() && !state.isLoading) {
|
||||||
if (state.isSearchActive && state.searchQuery.isNotBlank()) {
|
|
||||||
EmptyState(
|
|
||||||
title = "No matches",
|
|
||||||
subtitle = "No messages match \"${state.searchQuery}\"",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = "Ready to chat",
|
title = "Ready to chat",
|
||||||
subtitle = "Send a message to start a conversation",
|
subtitle = "Send a message to start a conversation",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,6 +708,8 @@ 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(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -718,6 +720,8 @@ private fun SearchBarRow(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("Search messages…") },
|
placeholder = { Text("Search messages…") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (searchQuery.isNotEmpty()) {
|
if (searchQuery.isNotEmpty()) {
|
||||||
IconButton(onClick = { onQueryChange("") }) {
|
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(
|
||||||
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) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.KeyboardArrowUp,
|
Icons.Filled.KeyboardArrowUp,
|
||||||
contentDescription = "Previous match",
|
contentDescription = "Previous match",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onNavigateDown) {
|
IconButton(onClick = onNavigateDown, enabled = searchMatchCount > 0) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.KeyboardArrowDown,
|
Icons.Filled.KeyboardArrowDown,
|
||||||
contentDescription = "Next match",
|
contentDescription = "Next match",
|
||||||
|
|||||||
Reference in new issue
Block a user