From f9a3491ee91f7e326f0264600dfe57492f0e228a Mon Sep 17 00:00:00 2001 From: Massimiliano Pili <115225690+MassimilianoPili@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:53:58 +0000 Subject: [PATCH 1/2] feat(search): in-chat message search and session list filter Search within the open conversation across text and reasoning parts, with a match counter, previous/next navigation and scroll-to-match highlight. Add a session-list title filter reachable from the sessions top bar. Both mirror the existing file/command search UX (transparent-bordered field, TUI glyphs). --- .../p4oc/ui/screens/chat/ChatScreen.kt | 113 +++++++++++++--- .../p4oc/ui/screens/chat/ChatSearch.kt | 53 ++++++++ .../p4oc/ui/screens/chat/ChatSearchBar.kt | 127 ++++++++++++++++++ .../ui/screens/sessions/SessionListScreen.kt | 114 +++++++++++++++- app/src/main/res/values/strings.xml | 5 + 5 files changed, 383 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearch.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index f8993b2..bcbc3fc 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -3,10 +3,11 @@ package dev.blazelight.p4oc.ui.screens.chat import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* 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.material3.* import androidx.compose.runtime.* @@ -135,6 +136,13 @@ fun ChatScreen( var showFilePicker by remember { mutableStateOf(false) } var showRevertDialog by remember { mutableStateOf(null) } + // In-chat search: query + current hit, reset whenever the open session changes. + var showSearch by remember(uiState.session?.id) { mutableStateOf(false) } + var searchQuery by remember(uiState.session?.id) { mutableStateOf("") } + var currentMatchIndex by remember(uiState.session?.id) { mutableStateOf(0) } + val messageBlocks = remember(messages) { groupMessagesIntoBlocks(messages) } + val searchMatches = remember(messageBlocks, searchQuery) { findChatMatches(messageBlocks, searchQuery) } + // Scroll UX state: follow new tail content only while the user remains pinned to bottom. var shouldFollowTail by remember(uiState.session?.id) { mutableStateOf(true) } var hasNewContentWhileAway by remember { mutableStateOf(false) } @@ -160,9 +168,14 @@ fun ChatScreen( val keyboardController = LocalSoftwareKeyboardController.current BackHandler { - focusManager.clearFocus() - keyboardController?.hide() - onNavigateBack() + if (showSearch) { + showSearch = false + searchQuery = "" + } else { + focusManager.clearFocus() + keyboardController?.hide() + onNavigateBack() + } } // Match the sticky follow-tail model: only update follow state after the user's scroll settles. @@ -213,6 +226,17 @@ fun ChatScreen( } } + // Keep the active hit in range when matches change, and scroll it into view. + LaunchedEffect(searchMatches.size) { + if (currentMatchIndex >= searchMatches.size) currentMatchIndex = 0 + } + LaunchedEffect(currentMatchIndex, searchMatches) { + searchMatches.getOrNull(currentMatchIndex)?.let { match -> + shouldFollowTail = false + listState.scrollToItem(match.blockIndex) + } + } + Scaffold( topBar = { ChatTopBar( @@ -221,6 +245,10 @@ fun ChatScreen( onBack = onNavigateBack, onTerminal = onOpenTerminal, onFiles = onOpenFiles, + onSearch = { + showSearch = true + currentMatchIndex = 0 + }, onCommands = { viewModel.refreshCommandsIfNeeded(force = true) showCommandPalette = true @@ -295,11 +323,38 @@ fun ChatScreen( } } ) { padding -> - Box( + Column( modifier = Modifier .fillMaxSize() .padding(padding) ) { + if (showSearch) { + ChatSearchBar( + query = searchQuery, + onQueryChange = { searchQuery = it }, + matchCount = searchMatches.size, + currentIndex = currentMatchIndex, + onPrev = { + if (searchMatches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex - 1 + searchMatches.size) % searchMatches.size + } + }, + onNext = { + if (searchMatches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex + 1) % searchMatches.size + } + }, + onClose = { + showSearch = false + searchQuery = "" + }, + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { // Revert active banner uiState.session?.revert?.let { val theme = LocalOpenCodeTheme.current @@ -334,10 +389,6 @@ fun ChatScreen( if (!hasContent && !uiState.isLoading) { EmptyChatView(modifier = Modifier.align(Alignment.Center)) } else { - val messageBlocks = remember(messages) { - groupMessagesIntoBlocks(messages) - } - LazyColumn( state = listState, modifier = Modifier.fillMaxSize().testTag("message_list"), @@ -345,25 +396,34 @@ fun ChatScreen( verticalArrangement = Arrangement.spacedBy(Spacing.hairline), ) { // All messages - stable keys ensure only changed items recompose - items( + itemsIndexed( items = messageBlocks, - key = { block -> + key = { _, block -> when (block) { is MessageBlock.UserBlock -> block.message.message.id is MessageBlock.AssistantBlock -> block.messages.first().message.id } } - ) { block -> - MessageBlockView( - block = block, - onToolApprove = { viewModel.respondToPermission(it, "once") }, - onToolDeny = { viewModel.respondToPermission(it, "reject") }, - onToolAlways = { viewModel.respondToPermission(it, "always") }, - onOpenSubSession = onOpenSubSession, - defaultToolWidgetState = defaultToolWidgetState, - pendingPermissionsByCallId = pendingPermissionsByCallId, - onRevert = { messageId -> showRevertDialog = messageId } - ) + ) { index, block -> + val isCurrentMatch = showSearch && searchQuery.isNotBlank() && + searchMatches.getOrNull(currentMatchIndex)?.blockIndex == index + val highlight = if (isCurrentMatch) { + Modifier.background(LocalOpenCodeTheme.current.accent.copy(alpha = 0.08f)) + } else { + Modifier + } + Box(modifier = highlight) { + MessageBlockView( + block = block, + onToolApprove = { viewModel.respondToPermission(it, "once") }, + onToolDeny = { viewModel.respondToPermission(it, "reject") }, + onToolAlways = { viewModel.respondToPermission(it, "always") }, + onOpenSubSession = onOpenSubSession, + defaultToolWidgetState = defaultToolWidgetState, + pendingPermissionsByCallId = pendingPermissionsByCallId, + onRevert = { messageId -> showRevertDialog = messageId } + ) + } } pendingQuestion?.let { questionRequest -> @@ -423,6 +483,7 @@ fun ChatScreen( .align(Alignment.BottomEnd) .padding(end = Spacing.xl, bottom = Spacing.md) ) + } } } @@ -498,6 +559,7 @@ private fun ChatTopBar( onBack: () -> Unit, onTerminal: () -> Unit, onFiles: () -> Unit, + onSearch: () -> Unit, onCommands: () -> Unit, onViewChanges: () -> Unit, branchName: String? = null, @@ -561,6 +623,13 @@ private fun ChatTopBar( expanded = showOverflow, onDismissRequest = { showOverflow = false } ) { + TuiDropdownMenuItem( + text = "⌕ ${stringResource(R.string.chat_search_action)}", + onClick = { + showOverflow = false + onSearch() + } + ) TuiDropdownMenuItem( text = "± ${stringResource(R.string.sessions_view_changes)}", onClick = { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearch.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearch.kt new file mode 100644 index 0000000..9395af4 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearch.kt @@ -0,0 +1,53 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import dev.blazelight.p4oc.domain.model.MessageWithParts +import dev.blazelight.p4oc.domain.model.Part + +/** + * A search hit inside the open conversation: the LazyColumn block index that + * contains the query (used to scroll the match into view) plus the id of the + * first message in that block (stable identity for highlighting). + */ +internal data class ChatSearchMatch( + val blockIndex: Int, + val messageId: String, +) + +/** + * The plain text of a message a user could meaningfully search: visible content + * ([Part.Text]), model reasoning ([Part.Reasoning]), invoked tool names and + * sub-task prompts. Binary/structural parts (files, patches, snapshots) are skipped. + */ +internal fun MessageWithParts.searchableText(): String = buildString { + parts.forEach { part -> + when (part) { + is Part.Text -> append(part.text).append('\n') + is Part.Reasoning -> append(part.text).append('\n') + is Part.Tool -> append(part.toolName).append('\n') + is Part.Subtask -> append(part.prompt).append('\n').append(part.description).append('\n') + else -> {} + } + } +} + +/** + * Blocks containing [query] (case-insensitive substring), in display order. + * Returns an empty list for a blank query. Pure function — trivially testable + * and recomputed via `remember(blocks, query)` on each keystroke. + */ +internal fun findChatMatches(blocks: List, query: String): List { + val needle = query.trim() + if (needle.isEmpty()) return emptyList() + val matches = ArrayList() + blocks.forEachIndexed { index, block -> + val messages = when (block) { + is MessageBlock.UserBlock -> listOf(block.message) + is MessageBlock.AssistantBlock -> block.messages + } + val firstId = messages.firstOrNull()?.message?.id ?: return@forEachIndexed + if (messages.any { it.searchableText().contains(needle, ignoreCase = true) }) { + matches.add(ChatSearchMatch(index, firstId)) + } + } + return matches +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt new file mode 100644 index 0000000..f01043c --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt @@ -0,0 +1,127 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing +import dev.blazelight.p4oc.ui.theme.Spacing + +/** + * In-chat search bar: a transparent-bordered query field plus a match counter + * ("3/12"), previous/next navigation and a close button. Mirrors the file/command + * search UX. Auto-focuses on appearance so the keyboard opens immediately. + */ +@Composable +internal fun ChatSearchBar( + query: String, + onQueryChange: (String) -> Unit, + matchCount: Int, + currentIndex: Int, + onPrev: () -> Unit, + onNext: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val theme = LocalOpenCodeTheme.current + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Surface(color = theme.backgroundPanel, shape = RectangleShape, modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.sm, vertical = Spacing.xxs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Text( + text = "⌕", + color = theme.accent, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleMedium, + ) + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { + Text( + text = stringResource(R.string.chat_search_placeholder), + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + color = theme.text, + ), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + cursorColor = theme.accent, + focusedTextColor = theme.text, + unfocusedTextColor = theme.text, + ), + ) + val counter = when { + query.isBlank() -> "" + matchCount == 0 -> stringResource(R.string.chat_search_no_matches) + else -> "${currentIndex + 1}/$matchCount" + } + if (counter.isNotEmpty()) { + Text( + text = counter, + color = theme.textMuted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.labelSmall, + ) + } + SearchGlyphButton("↑", enabled = matchCount > 0, onClick = onPrev, color = theme.accent, mutedColor = theme.textMuted) + SearchGlyphButton("↓", enabled = matchCount > 0, onClick = onNext, color = theme.accent, mutedColor = theme.textMuted) + SearchGlyphButton("✕", enabled = true, onClick = onClose, color = theme.textMuted, mutedColor = theme.textMuted) + } + } +} + +@Composable +private fun SearchGlyphButton( + glyph: String, + enabled: Boolean, + onClick: () -> Unit, + color: Color, + mutedColor: Color, +) { + IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(Sizing.iconButtonMd)) { + Text( + text = glyph, + color = if (enabled) color else mutedColor, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleSmall, + ) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt index ca04ed4..c459930 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt @@ -18,6 +18,9 @@ import androidx.compose.material3.MenuAnchorType import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -58,7 +61,7 @@ private data class SessionNode( get() = children.size + children.sumOf { it.totalDescendants } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SessionListScreen( viewModel: SessionListViewModel = koinViewModel(), @@ -83,6 +86,8 @@ fun SessionListScreen( var showNewSessionCustomDir by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(null) } var showRenameDialog by remember { mutableStateOf(null) } + var showSearch by remember { mutableStateOf(false) } + var sessionSearchQuery by remember { mutableStateOf("") } val context = LocalContext.current val filterDirectory = remember(uiState.projects, filterProjectId) { @@ -159,6 +164,19 @@ fun SessionListScreen( modifier = Modifier.size(Sizing.iconAction) ) } + IconButton( + onClick = { + showSearch = !showSearch + if (!showSearch) sessionSearchQuery = "" + }, + modifier = Modifier.size(Sizing.iconButtonMd).testTag("sessions_search_button") + ) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.cd_search), + modifier = Modifier.size(Sizing.iconAction) + ) + } IconButton( onClick = onSettings, modifier = Modifier.size(Sizing.iconButtonMd).testTag("sessions_settings_button") @@ -207,8 +225,17 @@ fun SessionListScreen( } else { val expandedSessions = remember { mutableStateMapOf() } - val sessionTree = remember(displayedSessions) { - buildSessionTree(displayedSessions) + // During search, flatten to matching sessions (tree roots only would + // hide matching child sessions whose parent is filtered out). + val sessionTree = remember(displayedSessions, sessionSearchQuery) { + val q = sessionSearchQuery.trim() + if (q.isBlank()) { + buildSessionTree(displayedSessions) + } else { + displayedSessions + .filter { it.session.title.contains(q, ignoreCase = true) } + .map { SessionNode(it, emptyList()) } + } } LazyColumn( @@ -216,8 +243,21 @@ fun SessionListScreen( contentPadding = PaddingValues(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs) ) { - // Pinned quick actions (only on unfiltered list) - if (filterProjectId == null) { + val searchActive = sessionSearchQuery.isNotBlank() + if (showSearch) { + stickyHeader(key = "session_search") { + SessionSearchField( + query = sessionSearchQuery, + onQueryChange = { sessionSearchQuery = it }, + onClose = { + showSearch = false + sessionSearchQuery = "" + }, + ) + } + } + // Pinned quick actions (hidden while searching) + if (!searchActive && filterProjectId == null) { item(key = "quick_action_global") { QuickActionCard( icon = "\u25C6", @@ -244,7 +284,7 @@ fun SessionListScreen( modifier = Modifier.testTag("quick_action_custom") ) } - } else { + } else if (!searchActive) { filterDirectory?.let { directory -> item(key = "quick_action_project") { QuickActionCard( @@ -262,7 +302,16 @@ fun SessionListScreen( } } - if (displayedSessions.isEmpty() && filterProjectId == null) { + if (searchActive && sessionTree.isEmpty()) { + item(key = "no_match") { + Text( + text = stringResource(R.string.sessions_search_no_match), + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.lg) + ) + } + } else if (displayedSessions.isEmpty() && filterProjectId == null) { item(key = "empty_hint") { Text( text = stringResource(R.string.sessions_empty_hint), @@ -384,6 +433,57 @@ fun SessionListScreen( } } +@Composable +private fun SessionSearchField( + query: String, + onQueryChange: (String) -> Unit, + onClose: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + Surface(color = theme.backgroundPanel, shape = RectangleShape, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.sm, vertical = Spacing.xxs), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { + Text( + text = stringResource(R.string.sessions_search_placeholder), + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy(color = theme.text), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + cursorColor = theme.accent, + focusedTextColor = theme.text, + unfocusedTextColor = theme.text, + ), + ) + IconButton(onClick = onClose, modifier = Modifier.size(Sizing.iconButtonMd)) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.button_cancel), + modifier = Modifier.size(Sizing.iconAction), + tint = theme.textMuted, + ) + } + } + } +} + private fun buildSessionTree(sessions: List): List { val childrenByParent = sessions .mapNotNull { swp -> swp.session.parentID?.let { parentId -> parentId to swp } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9da17a..2709f36 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,8 @@ Sessions No sessions yet Tap above to start a new session + Search chats… + No matching chats Loading sessions New Session Delete @@ -174,6 +176,9 @@ Create session in %1$s + Search chat + Search in chat… + No matches Start a conversation Type a message below to begin Type a message… From 8014be753aefbd73d93bb445dfe6a46669380ed9 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sun, 28 Jun 2026 13:23:09 +0200 Subject: [PATCH 2/2] fix(search): add semantics to search controls --- .../p4oc/ui/screens/chat/ChatSearchBar.kt | 47 +++++++++++++++++-- .../ui/screens/sessions/SessionListScreen.kt | 3 +- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt index f01043c..f41547c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt @@ -20,7 +20,10 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import dev.blazelight.p4oc.R import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme @@ -79,7 +82,8 @@ internal fun ChatSearchBar( ), modifier = Modifier .weight(1f) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .testTag("chat_search_field"), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, @@ -101,9 +105,33 @@ internal fun ChatSearchBar( style = MaterialTheme.typography.labelSmall, ) } - SearchGlyphButton("↑", enabled = matchCount > 0, onClick = onPrev, color = theme.accent, mutedColor = theme.textMuted) - SearchGlyphButton("↓", enabled = matchCount > 0, onClick = onNext, color = theme.accent, mutedColor = theme.textMuted) - SearchGlyphButton("✕", enabled = true, onClick = onClose, color = theme.textMuted, mutedColor = theme.textMuted) + SearchGlyphButton( + glyph = "↑", + contentDescription = stringResource(R.string.previous), + testTag = "chat_search_previous", + enabled = matchCount > 0, + onClick = onPrev, + color = theme.accent, + mutedColor = theme.textMuted, + ) + SearchGlyphButton( + glyph = "↓", + contentDescription = stringResource(R.string.next), + testTag = "chat_search_next", + enabled = matchCount > 0, + onClick = onNext, + color = theme.accent, + mutedColor = theme.textMuted, + ) + SearchGlyphButton( + glyph = "✕", + contentDescription = stringResource(R.string.button_cancel), + testTag = "chat_search_close", + enabled = true, + onClick = onClose, + color = theme.textMuted, + mutedColor = theme.textMuted, + ) } } } @@ -111,12 +139,21 @@ internal fun ChatSearchBar( @Composable private fun SearchGlyphButton( glyph: String, + contentDescription: String, + testTag: String, enabled: Boolean, onClick: () -> Unit, color: Color, mutedColor: Color, ) { - IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(Sizing.iconButtonMd)) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .size(Sizing.iconButtonMd) + .semantics { this.contentDescription = contentDescription } + .testTag(testTag), + ) { Text( text = glyph, color = if (enabled) color else mutedColor, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt index c459930..6a68008 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt @@ -463,7 +463,8 @@ private fun SessionSearchField( textStyle = MaterialTheme.typography.bodySmall.copy(color = theme.text), modifier = Modifier .weight(1f) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .testTag("sessions_search_field"), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent,