Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 91 additions & 22 deletions app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -137,6 +138,13 @@ fun ChatScreen(
var showFilePicker by remember { mutableStateOf(false) }
var showRevertDialog by remember { mutableStateOf<String?>(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) }
Expand All @@ -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.
Expand Down Expand Up @@ -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)) {
Expand All @@ -220,6 +244,10 @@ fun ChatScreen(
onBack = onNavigateBack,
onTerminal = onOpenTerminal,
onFiles = onOpenFiles,
onSearch = {
showSearch = true
currentMatchIndex = 0
},
onCommands = {
viewModel.refreshCommandsIfNeeded(force = true)
showCommandPalette = true
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -333,36 +388,41 @@ 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"),
contentPadding = PaddingValues(vertical = Spacing.xxs, horizontal = Spacing.xs),
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 ->
Expand Down Expand Up @@ -422,6 +482,7 @@ fun ChatScreen(
.align(Alignment.BottomEnd)
.padding(end = Spacing.xl, bottom = Spacing.md)
)
}
}
}

Expand Down Expand Up @@ -497,6 +558,7 @@ private fun ChatTopBar(
onBack: () -> Unit,
onTerminal: () -> Unit,
onFiles: () -> Unit,
onSearch: () -> Unit,
onCommands: () -> Unit,
onViewChanges: () -> Unit,
branchName: String? = null,
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MessageBlock>, query: String): List<ChatSearchMatch> {
val needle = query.trim()
if (needle.isEmpty()) return emptyList()
val matches = ArrayList<ChatSearchMatch>()
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
}
Loading
Loading