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,
|
||||
viewModel: ChatViewModel,
|
||||
) {
|
||||
// Search bar
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = 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),
|
||||
),
|
||||
// Lay children out vertically so the search bar occupies real layout space
|
||||
// ABOVE the message list. Without this container the call site is a Box,
|
||||
// which overlays the LazyColumn on top of the search AnimatedVisibility and
|
||||
// swallows every tap on the bar (bar visible but not clickable).
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Search bar
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isSearchActive,
|
||||
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
||||
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
SearchBarRow(
|
||||
searchQuery = searchQuery,
|
||||
onQueryChange = { viewModel.setSearchQuery(it) },
|
||||
searchMatchCount = searchMatchIndices.size,
|
||||
currentMatchIndex = currentSearchMatchIndex,
|
||||
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
||||
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
||||
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
|
||||
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),
|
||||
),
|
||||
) {
|
||||
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,
|
||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
SearchBarRow(
|
||||
searchQuery = searchQuery,
|
||||
onQueryChange = { viewModel.setSearchQuery(it) },
|
||||
searchMatchCount = searchMatchIndices.size,
|
||||
currentMatchIndex = currentSearchMatchIndex,
|
||||
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
||||
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
||||
onClose = { viewModel.clearSearch() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking indicator
|
||||
if (isThinking) {
|
||||
item(key = "thinking") {
|
||||
ThinkingIndicator(thinkingText)
|
||||
if (messages.isEmpty() && !isLoading) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.chat_empty_title),
|
||||
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