From 089eaf06e53d6491f58a436c7e96daeb308e57b0 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:22:51 +0300 Subject: [PATCH] fix(chat): make search bar clickable (was overlayed by message list) (#492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 194 +++++++++--------- 1 file changed, 100 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 6c9f90ab..13a0f725 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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) + } } } }