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 a6bdf99..2c204cc 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,11 +3,12 @@ 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.LazyListState -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.* @@ -137,6 +138,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 didInitialTailScroll by remember(uiState.session?.id) { mutableStateOf(false) } @@ -163,9 +171,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. @@ -203,6 +216,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) + } + } + // The loading screen hides the list; once the session content is visible, land at the tail. LaunchedEffect(uiState.session?.id, uiState.isLoading, messageCount, pendingQuestionId) { if (!didInitialTailScroll && !uiState.isLoading && (messages.isNotEmpty() || pendingQuestionId != null)) { @@ -220,6 +244,10 @@ fun ChatScreen( onBack = onNavigateBack, onTerminal = onOpenTerminal, onFiles = onOpenFiles, + onSearch = { + showSearch = true + currentMatchIndex = 0 + }, onCommands = { viewModel.refreshCommandsIfNeeded(force = true) showCommandPalette = true @@ -294,11 +322,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 @@ -333,10 +388,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"), @@ -344,25 +395,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 -> @@ -422,6 +482,7 @@ fun ChatScreen( .align(Alignment.BottomEnd) .padding(end = Spacing.xl, bottom = Spacing.md) ) + } } } @@ -497,6 +558,7 @@ private fun ChatTopBar( onBack: () -> Unit, onTerminal: () -> Unit, onFiles: () -> Unit, + onSearch: () -> Unit, onCommands: () -> Unit, onViewChanges: () -> Unit, branchName: String? = null, @@ -560,6 +622,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..f41547c --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatSearchBar.kt @@ -0,0 +1,164 @@ +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.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 +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) + .testTag("chat_search_field"), + 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( + 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, + ) + } + } +} + +@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) + .semantics { this.contentDescription = contentDescription } + .testTag(testTag), + ) { + 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..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 @@ -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,58 @@ 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) + .testTag("sessions_search_field"), + 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…