fix(chat): make search bar clickable (was overlayed by message list) (#492)
* fix(chat): make search bar clickable by giving ChatMessageList a vertical layout The search bar (AnimatedVisibility) and the message LazyColumn were emitted as siblings with no container. The call site is a Box, which overlays children — so the LazyColumn rendered on top of the search bar, intercepting every pointer event. The bar stayed visible but was dead (nothing inside clickable: input, nav arrows, close). Wrap ChatMessageList's children in a Column so the bar occupies real layout space above the list; the LazyColumn now takes weight(1f) and scrolls independently. Fixes #490. * refactor(chat): simplify search-list LazyColumn modifier Use fillMaxWidth().weight(1f) instead of fillMaxSize().weight(1f) inside the new Column — weight already claims the remaining vertical space, so fillMaxSize was redundant/contradictory (Sourcery review nit). Note: the Sourcery 'use LocalHermesStatusColors' suggestion on the search bar Surface is a false positive — that rule covers SEMANTIC status colors (success/warning/error/info) only. surfaceContainerLow/outline are neutral Material surface chrome, not status colors; LocalHermesStatusColors does not even expose them. The corresponding status dot correctly uses StatusRed.
This commit is contained in:
1 file changed
+100
-94
@@ -1402,107 +1402,113 @@ private fun ChatMessageList(
|
|||||||
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
) {
|
) {
|
||||||
// Search bar
|
// Lay children out vertically so the search bar occupies real layout space
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
// ABOVE the message list. Without this container the call site is a Box,
|
||||||
visible = isSearchActive,
|
// which overlays the LazyColumn on top of the search AnimatedVisibility and
|
||||||
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
// swallows every tap on the bar (bar visible but not clickable).
|
||||||
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
) {
|
// Search bar
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
visible = isSearchActive,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
||||||
tonalElevation = 2.dp,
|
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
||||||
border =
|
|
||||||
BorderStroke(
|
|
||||||
width = 1.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
androidx.compose.material3.Surface(
|
||||||
SearchBarRow(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
searchQuery = searchQuery,
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
onQueryChange = { viewModel.setSearchQuery(it) },
|
tonalElevation = 2.dp,
|
||||||
searchMatchCount = searchMatchIndices.size,
|
border =
|
||||||
currentMatchIndex = currentSearchMatchIndex,
|
BorderStroke(
|
||||||
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
width = 1.dp,
|
||||||
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
|
||||||
onClose = { viewModel.clearSearch() },
|
),
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.isEmpty() && !isLoading) {
|
|
||||||
EmptyState(
|
|
||||||
title = stringResource(R.string.chat_empty_title),
|
|
||||||
subtitle = stringResource(R.string.chat_empty_subtitle),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
itemsIndexed(
|
|
||||||
items = messages,
|
|
||||||
key = { _, message -> message.id },
|
|
||||||
) { index, message ->
|
|
||||||
val isCurrentMatch =
|
|
||||||
isSearchActive &&
|
|
||||||
currentSearchMatchIndex >= 0 &&
|
|
||||||
currentSearchMatchIndex < searchMatchIndices.size &&
|
|
||||||
searchMatchIndices[currentSearchMatchIndex] == index
|
|
||||||
|
|
||||||
val isLastMessage = index == messages.lastIndex
|
|
||||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
|
||||||
|
|
||||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
|
||||||
lastAnimatedMessageId != message.id
|
|
||||||
) {
|
) {
|
||||||
StreamingBubbleWithTypingEffect(
|
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
streaming = message,
|
SearchBarRow(
|
||||||
typingDelayMs = typingEffectDelayMs,
|
searchQuery = searchQuery,
|
||||||
isDark = isDark,
|
onQueryChange = { viewModel.setSearchQuery(it) },
|
||||||
onAnimationComplete = {
|
searchMatchCount = searchMatchIndices.size,
|
||||||
onLastAnimatedMessageIdChange(message.id)
|
currentMatchIndex = currentSearchMatchIndex,
|
||||||
},
|
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
||||||
)
|
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
||||||
} else {
|
onClose = { viewModel.clearSearch() },
|
||||||
ChatBubble(
|
|
||||||
message = message,
|
|
||||||
isDarkTheme = isDark,
|
|
||||||
searchQuery = if (isSearchActive) searchQuery else "",
|
|
||||||
isCurrentMatch = isCurrentMatch,
|
|
||||||
onRespondApproval = viewModel::respondToApproval,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming message
|
|
||||||
streamingMessage?.let { streaming ->
|
|
||||||
item(key = "streaming-${streaming.id}") {
|
|
||||||
if (typingEffectEnabled && streaming.isStreaming) {
|
|
||||||
StreamingBubbleWithTypingEffect(
|
|
||||||
streaming = streaming,
|
|
||||||
typingDelayMs = typingEffectDelayMs,
|
|
||||||
isDark = isDark,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ChatBubble(
|
|
||||||
message = streaming,
|
|
||||||
isDarkTheme = isDark,
|
|
||||||
searchQuery = "",
|
|
||||||
isCurrentMatch = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thinking indicator
|
if (messages.isEmpty() && !isLoading) {
|
||||||
if (isThinking) {
|
EmptyState(
|
||||||
item(key = "thinking") {
|
title = stringResource(R.string.chat_empty_title),
|
||||||
ThinkingIndicator(thinkingText)
|
subtitle = stringResource(R.string.chat_empty_subtitle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = messages,
|
||||||
|
key = { _, message -> message.id },
|
||||||
|
) { index, message ->
|
||||||
|
val isCurrentMatch =
|
||||||
|
isSearchActive &&
|
||||||
|
currentSearchMatchIndex >= 0 &&
|
||||||
|
currentSearchMatchIndex < searchMatchIndices.size &&
|
||||||
|
searchMatchIndices[currentSearchMatchIndex] == index
|
||||||
|
|
||||||
|
val isLastMessage = index == messages.lastIndex
|
||||||
|
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||||
|
|
||||||
|
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||||
|
lastAnimatedMessageId != message.id
|
||||||
|
) {
|
||||||
|
StreamingBubbleWithTypingEffect(
|
||||||
|
streaming = message,
|
||||||
|
typingDelayMs = typingEffectDelayMs,
|
||||||
|
isDark = isDark,
|
||||||
|
onAnimationComplete = {
|
||||||
|
onLastAnimatedMessageIdChange(message.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChatBubble(
|
||||||
|
message = message,
|
||||||
|
isDarkTheme = isDark,
|
||||||
|
searchQuery = if (isSearchActive) searchQuery else "",
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
onRespondApproval = viewModel::respondToApproval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming message
|
||||||
|
streamingMessage?.let { streaming ->
|
||||||
|
item(key = "streaming-${streaming.id}") {
|
||||||
|
if (typingEffectEnabled && streaming.isStreaming) {
|
||||||
|
StreamingBubbleWithTypingEffect(
|
||||||
|
streaming = streaming,
|
||||||
|
typingDelayMs = typingEffectDelayMs,
|
||||||
|
isDark = isDark,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChatBubble(
|
||||||
|
message = streaming,
|
||||||
|
isDarkTheme = isDark,
|
||||||
|
searchQuery = "",
|
||||||
|
isCurrentMatch = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking indicator
|
||||||
|
if (isThinking) {
|
||||||
|
item(key = "thinking") {
|
||||||
|
ThinkingIndicator(thinkingText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user