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:
3 files changed
+224
-83
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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.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.BasicTextField
|
||||
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 +82,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,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
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HermesScaffold(
|
||||
modifier = modifier,
|
||||
pinTopBar = true,
|
||||
title = {
|
||||
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() },
|
||||
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),
|
||||
)
|
||||
} 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,
|
||||
@@ -330,24 +321,46 @@ fun ChatScreen(
|
||||
.background(backgroundGradient)
|
||||
.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(
|
||||
modifier =
|
||||
Modifier
|
||||
.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 +368,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
private fun SearchBarRow(
|
||||
searchQuery: String,
|
||||
@@ -708,45 +794,70 @@ private fun SearchBarRow(
|
||||
onNavigateDown: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val isError = searchQuery.isNotEmpty() && searchMatchCount == 0
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
CompactSearchInput(
|
||||
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")
|
||||
}
|
||||
}
|
||||
},
|
||||
isError = isError,
|
||||
)
|
||||
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,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = "Previous match",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNavigateDown) {
|
||||
IconButton(
|
||||
onClick = onNavigateDown,
|
||||
enabled = searchMatchCount > 0,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = "Next match",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close search")
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Close search",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,17 @@ fun HermesScaffold(
|
||||
isRefreshing: Boolean = false,
|
||||
onBack: (() -> Unit)? = null,
|
||||
showBack: Boolean = false,
|
||||
pinTopBar: Boolean = false,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
actions: @Composable () -> Unit = {},
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
val scrollBehavior =
|
||||
if (pinTopBar) {
|
||||
TopAppBarDefaults.pinnedScrollBehavior()
|
||||
} else {
|
||||
TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
|
||||
Reference in new issue
Block a user