fix(chat): snappy in-chat search over visible text only (#887)

* fix(chat): snappy in-chat search over visible text only

- findMatches now scans USER + ASSISTANT messages only — tool rows and
  system events were matched before, so navigation jumped to invisible
  matches and the scan paid for the chat's heaviest strings (tool payloads)
- Reasoning text is explicitly out of scope (lives in reasoningText, never
  highlighted) — documented in the controller contract
- Debounce (150ms) coalesces typing into one scan instead of one full scan
  per keystroke; blanking the query still clears instantly
- Tests: controller scope (user/assistant only, tool/system/reasoning
  excluded, case-insensitive, navigation wrap) + delegate debounce
  (coalescing, cancelled intermediate queries, instant blank clear)

* fix(chat): search navigation lands the match mid-view, not at an edge

scrollToSearchMatch top-aligned the matched message with
animateScrollToItem(index), pinning it to the viewport edge — tall
messages left the highlighted word above/below the visible area.
Now top-aligns, then nudges the item down to ~1/3 viewport height
(verified top-align -> measure -> adjust pattern, same as scrollToBottom).

* fix(chat): search scroll targets the real lazy item and the actual word

ROOT CAUSE: search-match indices are MESSAGE-list indices, but the
LazyColumn has extra items (reasoning hoists, tool rows, loading-older),
so scrollToSearchMatch(index) landed on the WRONG item whenever reasoning
or tools preceded the match — the 'does nothing / word under or above the
view' bug from the start.

- messageIdToLazyIndex(): pure messageId -> LazyColumn item index mapping
  mirroring FullBleedChatList's emission order (verified in tests)
- search-scroll effect moved INTO FullBleedChatList (the only composable
  that knows the mapping), serialized through the same ChatScrollController
- word-level targeting: each SearchMatch now carries its character offset
  in the message; the scroll places offset/contentLength x item height at
  ~1/3 viewport, so the highlighted word itself lands in view on tall or
  multi-occurrence messages
- counter/navigation now count OCCURRENCES (word hits), one entry per hit
  with a parallel searchMatchOffsets list; per-message dedupe removed
- tests: lazy-index mapping (reasoning hoist, tools, leading items),
  SearchMatch offsets, occurrence flattening, regex-literal queries

* fix(ci): pass searchMatchOffsets and scrollController to FullBleedChatList in instrumented test

The render helper in FullBleedChatListTest still used the pre-offset
signature, so compileDebugAndroidTestKotlin failed on the emulator job.
Verified locally with compileDebugAndroidTestKotlin + ktlintCheck.

* perf(chat): allocation-free indexOf search matcher with capped results

- findMatches scans with String.indexOf(ignoreCase=true) on the ORIGINAL
  content instead of Regex(Pattern.quote()).findAll: queries are always
  literal, so regex was pure overhead — a compiled Pattern plus one
  MatchResult allocation per hit. Offsets stay exact (no lowercase
  transform, which can change string length on some scripts).
- returns SearchResult(matches, totalMatches, capped): results are capped
  at 500 stored entries (MAX_SEARCH_MATCHES) to bound allocations and
  navigation on degenerate queries, while the counter still shows the
  exact total (searchMatchTotal/searchMatchCapped in ChatUiState).
- 14 controller tests green, delegate behavior unchanged.

* perf(chat): O(1) current-match lookup and highlight only matched bubbles

Two recomposition hot spots killed in the list renderer:

- isCurrentMatchFor() did messages.indexOfFirst per rendered bubble per
  search update — O(n) per item, O(n^2) per update on the main thread.
  Now currentMatchMessageId() resolves the current match's message id
  ONCE per recomposition and items compare ids (O(1)).
- every prose bubble re-ran its highlight scan on every search-state
  change even with zero hits. matchedMessageIds() computes the matched
  id set once; bubbles outside it get an empty searchQuery and skip the
  scan entirely.

Both helpers are pure, tested in FullBleedTurnsTest (18 tests), and the
instrumented test target still compiles.

* perf(chat): dedicated ChatSearchState holder — search no longer recomposes the list

Search fields lived in ChatUiState, so every debounced scan result changed
the whole state object: ChatScreen recomposed, FullBleedChatList's params
changed, and the entire list body re-ran (turns loop + item registrations)
on the main thread per search update.

Search state now lives in a @Stable snapshot-backed ChatSearchState
(LazyListState pattern) owned by ChatSearchDelegate:
- the list takes ONE stable param; the instance never changes, so the
  body is skipped on search updates — only the scopes that read a field
  recompose (search bar, matched bubbles, the scroll effect)
- matchedIds and currentMatchId are computed once per scan/navigate in
  the delegate and stored as state fields — items do O(1) id lookups
  inside their own lambdas, no body-level derivation
- ChatUiState loses the 7 search fields; ChatViewModel exposes
  viewModel.searchState; delegate tests now assert on the holder

19 search-related unit tests green, androidTest compiles.

* test(chat): long-session search sanity (2000 msgs) — exact counts, valid offsets

Synthetic 2000-message / ~1KB session verifying the matcher at scale:
- frequent word: exact 32_000 total, capped storage, every stored offset
  points at a real occurrence
- unique word resolves to the right message; tool payloads stay excluded
- local measurement: full scan ~7ms (regex version allocates per hit)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-12 22:12:03 +03:00
1 parent 685b870cad
commit 546bd17b18
16 files changed
+895 -142

No files matched your search

@@ -146,6 +146,9 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
// Snapshot-backed search state — read directly so only the scopes that
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val lifecycleOwner = LocalLifecycleOwner.current
val listState = rememberLazyListState()
val scrollScope = rememberCoroutineScope()
@@ -402,9 +405,6 @@ fun ChatScreen(
errorMessage = state.errorMessage,
backgroundCompleteMessage = state.backgroundCompleteMessage,
openError = state.openError,
isSearchActive = state.isSearchActive,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
clarifyRequest = state.clarifyRequest,
sudoPrompt = state.sudoPrompt,
secretPrompt = state.secretPrompt,
@@ -477,9 +477,9 @@ fun ChatScreen(
IconButton(onClick = { viewModel.toggleSearch() }) {
Icon(
imageVector =
if (state.isSearchActive) Icons.Filled.Close else Icons.Filled.Search,
if (searchState.isActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription =
if (state.isSearchActive) {
if (searchState.isActive) {
stringResource(
R.string.chat_action_close_search,
)
@@ -519,7 +519,7 @@ fun ChatScreen(
}
AnimatedVisibility(
visible = state.isSearchActive,
visible = searchState.isActive,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
@@ -535,10 +535,11 @@ fun ChatScreen(
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
SearchBarRow(
searchQuery = state.searchQuery,
searchQuery = searchState.query,
onQueryChange = { viewModel.setSearchQuery(it) },
searchMatchCount = state.searchMatchIndices.size,
currentMatchIndex = state.currentSearchMatchIndex,
searchMatchCount = searchState.matchTotal,
searchMatchCapped = searchState.matchCapped,
currentMatchIndex = searchState.currentIndex,
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
onNavigateDown = { viewModel.navigateSearchMatch(1) },
onClose = { viewModel.clearSearch() },
@@ -583,10 +584,7 @@ fun ChatScreen(
FullBleedChatList(
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery,
currentSearchMatchIndex = state.currentSearchMatchIndex,
searchMatchIndices = state.searchMatchIndices,
searchState = searchState,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
@@ -594,6 +592,7 @@ fun ChatScreen(
isLoadingOlder = state.isLoadingOlder,
isDark = isDark,
listState = listState,
scrollController = scrollController,
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
@@ -1,5 +1,22 @@
package com.m57.hermescontrol.ui.chat
/**
* One search hit: which message it lives in and where in that message's
* visible content the word starts (character offset). The offset lets the
* chat list scroll the actual word into view, not just its message.
*/
data class SearchMatch(
val messageIndex: Int,
val contentOffset: Int,
)
/** Result of a search scan, including the exact total before any cap. */
data class SearchResult(
val matches: List<SearchMatch>,
val totalMatches: Int,
val capped: Boolean,
)
/**
* Pure search logic for in-chat text search.
*
@@ -7,16 +24,50 @@ package com.m57.hermescontrol.ui.chat
*/
class ChatSearchController {
/**
* Find all message indices where [query] appears in the content.
* Find all WORD OCCURRENCES of [query] in the conversation's VISIBLE
* text — user bubbles and agent prose only — flattened into one entry per
* occurrence, carrying each hit's character offset in its message.
*
* Tool rows, system events, and reasoning text are excluded:
* - they are not visible prose the user is looking for, so matches there
* were invisible (nothing highlighted) yet still navigated/scrolled to
* - tool payloads dominate the chat's byte weight, so scanning them made
* search slow on long sessions
* Reasoning lives in [ChatMessage.reasoningText] (not `content`), so it
* was never matched — this keeps it that way explicitly.
*
* Matching is a literal, allocation-light [String.indexOf] scan
* (`ignoreCase = true`) instead of a regex — queries are always quoted
* literally, so regex was pure overhead: one MatchResult allocation per
* hit plus a compiled Pattern per query. Scanning the ORIGINAL content
* keeps offsets exact (no lowercase transform, which can change string
* length on some scripts).
*
* Result is capped at [MAX_SEARCH_MATCHES] entries to bound allocations
* and navigation cost on degenerate queries (single letter, "the");
* [SearchResult.totalMatches] still reports the exact total.
*/
fun findMatches(
messages: List<ChatMessage>,
query: String,
): List<Int> {
if (query.isBlank()) return emptyList()
return messages.indices.filter { idx ->
messages[idx].content.contains(query, ignoreCase = true)
): SearchResult {
if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false)
val result = mutableListOf<SearchMatch>()
var total = 0
for ((idx, message) in messages.withIndex()) {
if (message.role != MessageRole.USER && message.role != MessageRole.ASSISTANT) continue
var from = 0
while (true) {
val hit = message.content.indexOf(query, from, ignoreCase = true)
if (hit < 0) break
total++
if (result.size < MAX_SEARCH_MATCHES) {
result.add(SearchMatch(idx, hit))
}
from = hit + query.length
}
}
return SearchResult(result, total, capped = total > MAX_SEARCH_MATCHES)
}
/**
@@ -34,4 +85,9 @@ class ChatSearchController {
else -> currentIndex
}
}
companion object {
/** Hard cap on stored match entries; totals beyond it show as `N+`. */
const val MAX_SEARCH_MATCHES = 500
}
}
@@ -1,103 +1,120 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.currentMatchMessageId
import com.m57.hermescontrol.ui.chat.fullbleed.matchedMessageIds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Holds chat in-message search state and logic, extracted from [ChatViewModel]
* to keep the god-object focused on messaging/session concerns.
*
* Behavior is identical to the original inline implementation: it mutates the
* shared [uiState] flow for the four search-related fields and reads `messages`
* from it when computing matches.
* Search state lives in a dedicated snapshot-backed [ChatSearchState] holder
* (NOT in [ChatUiState]): search updates then recompose only the scopes that
* read its fields (search bar, matched bubbles, the scroll effect) instead of
* the entire chat screen and list. [uiState] is still read for `messages`
* when computing matches.
*
* @param dispatcher The [CoroutineDispatcher] used for the (CPU-bound) search
* work. Defaults to [Dispatchers.Default] — the original behavior — but can
* be injected to reuse a caller's context or customize per environment.
* @param debounceMs How long to wait after the last keystroke before running
* the scan. Coalesces fast typing into one search instead of one full scan
* per character.
*/
class ChatSearchDelegate(
private val scope: CoroutineScope,
private val uiState: MutableStateFlow<ChatUiState>,
val searchState: ChatSearchState = ChatSearchState(),
private val searchController: ChatSearchController = ChatSearchController(),
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
private val debounceMs: Long = SEARCH_DEBOUNCE_MS,
) {
private var searchJob: Job? = null
fun toggleSearch() {
val current = uiState.value
if (current.isSearchActive) {
if (searchState.isActive) {
clearSearch()
} else {
uiState.update { it.copy(isSearchActive = true, searchQuery = "") }
searchState.isActive = true
searchState.query = ""
}
}
fun setSearchQuery(query: String) {
searchJob?.cancel()
searchState.query = query
if (query.isBlank()) {
uiState.update {
it.copy(
searchQuery = query,
searchMatchIndices = emptyList(),
currentSearchMatchIndex = -1,
)
}
resetMatches()
return
}
// Keep local state in sync immediately so UI feels responsive.
uiState.update {
it.copy(searchQuery = query)
}
searchJob = scope.launch(dispatcher) { runSearch(query) }
// Keep local state in sync immediately so UI feels responsive, then
// debounce: cancel any pending scan and only run one after typing
// pauses for debounceMs (fast typing = one scan, not one per char).
searchJob =
scope.launch(dispatcher) {
delay(debounceMs)
runSearch(query)
}
}
private fun runSearch(query: String) {
val messages = uiState.value.messages
val indices = searchController.findMatches(messages, query)
val result = searchController.findMatches(messages, query)
uiState.update {
// Only update indices if the search query hasn't changed in the meantime
if (it.searchQuery == query) {
it.copy(
searchMatchIndices = indices,
currentSearchMatchIndex = if (indices.isNotEmpty()) 0 else -1,
)
} else {
it
}
}
// Only update matches if the search query hasn't changed in the meantime.
if (searchState.query != query) return
searchState.matchIndices = result.matches.map { m -> m.messageIndex }
searchState.matchOffsets = result.matches.map { m -> m.contentOffset }
searchState.matchTotal = result.totalMatches
searchState.matchCapped = result.capped
searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1
searchState.matchedIds = matchedMessageIds(messages, searchState.matchIndices)
searchState.currentMatchId =
currentMatchMessageId(messages, searchState.matchIndices, searchState.currentIndex)
}
fun navigateSearchMatch(direction: Int) {
uiState.update { state ->
val indices = state.searchMatchIndices
if (indices.isEmpty()) return@update state
val newIdx =
searchController.navigate(
currentIndex = state.currentSearchMatchIndex,
matchCount = indices.size,
direction = direction,
)
state.copy(currentSearchMatchIndex = newIdx)
}
val indices = searchState.matchIndices
if (indices.isEmpty()) return
val newIdx =
searchController.navigate(
currentIndex = searchState.currentIndex,
matchCount = indices.size,
direction = direction,
)
searchState.currentIndex = newIdx
searchState.currentMatchId =
currentMatchMessageId(uiState.value.messages, indices, newIdx)
}
fun clearSearch() {
uiState.update {
it.copy(
isSearchActive = false,
searchQuery = "",
searchMatchIndices = emptyList(),
currentSearchMatchIndex = -1,
)
}
searchJob?.cancel()
searchState.isActive = false
searchState.query = ""
resetMatches()
}
private fun resetMatches() {
searchState.matchIndices = emptyList()
searchState.matchOffsets = emptyList()
searchState.matchTotal = 0
searchState.matchCapped = false
searchState.currentIndex = -1
searchState.matchedIds = emptySet()
searchState.currentMatchId = null
}
private companion object {
/** Typing pause (ms) before a search scan runs. */
const val SEARCH_DEBOUNCE_MS = 150L
}
}
@@ -0,0 +1,57 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Snapshot-backed holder for in-chat search state, owned by
* [ChatSearchDelegate] and read directly by composables.
*
* Being a STABLE instance with mutable [androidx.compose.runtime.State]
* fields — the LazyListState pattern — means search updates never change
* the instance itself, so composables that take it as a parameter (the
* full-bleed chat list) are SKIPPED on search changes; only the scopes
* that actually read a field (visible item lambdas, the scroll effect)
* recompose. That was the last big cost: search state used to live inside
* [ChatUiState], so every debounced scan re-executed the entire list body
* (turns loop + item registrations) on the main thread.
*/
@Stable
class ChatSearchState {
var isActive by mutableStateOf(false)
internal set
var query by mutableStateOf("")
internal set
/** Stored matches (capped at [ChatSearchController.MAX_SEARCH_MATCHES]). */
var matchIndices by mutableStateOf(emptyList<Int>())
internal set
/** Parallel to [matchIndices]: character offset of each occurrence. */
var matchOffsets by mutableStateOf(emptyList<Int>())
internal set
var currentIndex by mutableStateOf(-1)
internal set
/** Exact total before the cap; [matchCapped] renders the counter as `N+`. */
var matchTotal by mutableStateOf(0)
internal set
var matchCapped by mutableStateOf(false)
internal set
/**
* Ids of messages containing at least one match. Bubbles outside this
* set skip their highlight scan entirely.
*/
var matchedIds by mutableStateOf(emptySet<String>())
internal set
/** Id of the message holding the CURRENT match (O(1) highlight lookup). */
var currentMatchId by mutableStateOf<String?>(null)
internal set
}
@@ -265,11 +265,8 @@ data class ChatUiState(
val showSessionPicker: Boolean = false,
// /update confirm dialog (issue #862) — the command is handled client-side
val updateConfirmOpen: Boolean = false,
// Search state
val isSearchActive: Boolean = false,
val searchQuery: String = "",
val searchMatchIndices: List<Int> = emptyList(),
val currentSearchMatchIndex: Int = -1,
// Search state lives in ChatSearchState (searchDelegate.searchState) — a
// snapshot-backed holder, so search updates don't recompose the whole UI.
// Cached settings
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
@@ -481,6 +478,10 @@ class ChatViewModel(
uiState = _uiState,
dispatcher = searchDispatcher,
)
/** Snapshot-backed in-chat search state (see [ChatSearchDelegate]). */
val searchState: ChatSearchState
get() = searchDelegate.searchState
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
/**
@@ -2112,10 +2113,6 @@ class ChatViewModel(
sudoPrompt = null,
secretPrompt = null,
showSessionPicker = false,
isSearchActive = false,
searchQuery = "",
searchMatchIndices = emptyList(),
currentSearchMatchIndex = -1,
showModelPicker = false,
modelPickerLoading = false,
currentSessionModel = null,
@@ -32,9 +32,6 @@ fun ChatLifecycleEffects(
errorMessage: String?,
backgroundCompleteMessage: String?,
openError: String?,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
clarifyRequest: ClarifyUi?,
sudoPrompt: SudoPromptUi?,
secretPrompt: SecretPromptUi?,
@@ -134,17 +131,4 @@ fun ChatLifecycleEffects(
onDismiss = viewModel::dismissSecret,
)
}
// Scroll to current search match (serialized through the controller so it
// doesn't compete with auto-follow / FAB / send scrolls).
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
if (isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
messages.isNotEmpty()
) {
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
scrollController.scrollToSearchMatch(targetIndex.coerceIn(0, messages.lastIndex))
}
}
}
@@ -151,9 +151,36 @@ class ChatScrollController(
scope.launch { listState.scrollToItem(index, offset) }
}
/** Navigate to a search match index (serialized through [scope]). */
fun scrollToSearchMatch(index: Int) {
scope.launch { listState.animateScrollToItem(index) }
/**
* Navigate to a search match (serialized through [scope]).
*
* [index] is the LAZY-COLUMN item index (see [messageIdToLazyIndex] —
* message indices ≠ lazy indices once reasoning/tool items exist).
* Top-aligns the item, then repositions it so the matched WORD lands
* comfortably in view (~1/3 down the viewport): the word's position is
* estimated as textOffset / contentLength × measured item height. Mirrors
* the verified scrollToBottom pattern (top-align → measure → adjust).
*
* @param contentOffset character offset of the word in the message text.
* @param contentLength total length of the message text.
*/
fun scrollToSearchMatch(
index: Int,
contentOffset: Int,
contentLength: Int,
) {
scope.launch {
listState.animateScrollToItem(index)
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == index } ?: return@launch
val viewportHeight = info.viewportEndOffset - info.viewportStartOffset
val fraction = if (contentLength > 0) contentOffset.toFloat() / contentLength else 0f
val targetTop = (viewportHeight / 3) - (item.size * fraction).toInt()
val delta = item.offset - targetTop
if (delta != 0) {
listState.animateScrollBy(delta.toFloat())
}
}
}
}
@@ -101,6 +101,7 @@ fun SearchBarRow(
searchQuery: String,
onQueryChange: (String) -> Unit,
searchMatchCount: Int,
searchMatchCapped: Boolean = false,
currentMatchIndex: Int,
onNavigateUp: () -> Unit,
onNavigateDown: () -> Unit,
@@ -124,7 +125,9 @@ fun SearchBarRow(
if (searchQuery.isNotEmpty()) {
val countText =
if (searchMatchCount > 0) {
"${currentMatchIndex + 1}/$searchMatchCount"
// `500+` when the exact total exceeded the stored cap.
val total = if (searchMatchCapped) "$searchMatchCount+" else "$searchMatchCount"
"${currentMatchIndex + 1}/$total"
} else {
"0/0"
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
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
@@ -18,10 +19,12 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import com.m57.hermescontrol.ui.chat.toolCallMilestones
@@ -46,10 +49,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
fun FullBleedChatList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isSearchActive: Boolean,
searchQuery: String,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
searchState: ChatSearchState,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
maxToolCallsPerTurn: Int? = null,
@@ -57,6 +57,7 @@ fun FullBleedChatList(
isLoadingOlder: Boolean,
isDark: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
scrollController: ChatScrollController,
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
@@ -83,6 +84,37 @@ fun FullBleedChatList(
remember(messages, streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingMessage)
}
// messageId → LazyColumn item index. The lazy list has EXTRA items
// vs the message list (reasoning hoists, tool rows), so search-match
// scrolling must resolve the lazy index, not use the message index.
val lazyIndexById =
remember(turns, isLoadingOlder) {
messageIdToLazyIndex(turns, leadingItems = if (isLoadingOlder) 1 else 0)
}
// Scroll the current search match into view, word-focused. Lives here
// (not in ChatLifecycleEffects) because only this composable knows
// the message-id → lazy-item-index mapping. Reads search fields in
// the effect (not the body), so only this effect restarts on change.
LaunchedEffect(
searchState.isActive,
searchState.currentIndex,
searchState.matchIndices,
searchState.matchOffsets,
) {
if (searchState.isActive &&
searchState.currentIndex >= 0 &&
searchState.currentIndex < searchState.matchIndices.size
) {
val messageIndex = searchState.matchIndices[searchState.currentIndex]
if (messageIndex < 0 || messageIndex >= messages.size) return@LaunchedEffect
val lazyIndex = lazyIndexById[messages[messageIndex].id] ?: return@LaunchedEffect
val contentOffset = searchState.matchOffsets.getOrElse(searchState.currentIndex) { 0 }
val contentLength = messages[messageIndex].content.length
scrollController.scrollToSearchMatch(lazyIndex, contentOffset, contentLength)
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
@@ -111,15 +143,10 @@ fun FullBleedChatList(
Column(modifier = Modifier.padding(bottom = 12.dp)) {
renderChatBubble(
message = userMessage,
searchQuery = searchQuery,
searchQuery = if (searchState.isActive) searchState.query else "",
isCurrentMatch =
isCurrentMatchFor(
messages,
userMessage.id,
isSearchActive,
currentSearchMatchIndex,
searchMatchIndices,
),
searchState.currentMatchId != null &&
searchState.currentMatchId == userMessage.id,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
@@ -181,15 +208,19 @@ fun FullBleedChatList(
message = proseMessage,
showTurnHeader = showTurnHeader,
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
isCurrentMatchFor(
messages,
proseMessage.id,
isSearchActive,
currentSearchMatchIndex,
searchMatchIndices,
),
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
@@ -275,18 +306,3 @@ private fun renderChatBubble(
onImageClick = onImageClick,
)
}
private fun isCurrentMatchFor(
messages: List<ChatMessage>,
messageId: String,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
): Boolean {
if (!isSearchActive || currentSearchMatchIndex < 0 || currentSearchMatchIndex >= searchMatchIndices.size) {
return false
}
val index = messages.indexOfFirst { it.id == messageId }
if (index < 0) return false
return searchMatchIndices[currentSearchMatchIndex] == index
}
@@ -88,3 +88,89 @@ fun groupIntoTurnsWithStreaming(
}
return turns
}
/**
* Map every visible chat message id to its LazyColumn item index in the
* full-bleed renderer.
*
* The lazy list does NOT have one item per message: each agent turn with a
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, and
* tool rows / system events are items too. Scrolling a search match by raw
* message index therefore lands on the WRONG item whenever reasoning or tool
* rows precede the target — the classic "match is above/below the view" bug.
*
* Mirrors the item emission order in [FullBleedChatList] exactly:
* user turn → 1 item; agent turn → optional reasoning item, then one item per
* entry. [leadingItems] accounts for fixed items emitted before the turns
* (e.g. the `loading-older` spinner when paging).
*
* @return messageId → LazyColumn item index of the message's content item
* (prose items for agent messages; user items for user messages).
*/
fun messageIdToLazyIndex(
turns: List<ChatTurn>,
leadingItems: Int = 0,
): Map<String, Int> {
val map = mutableMapOf<String, Int>()
var itemIndex = leadingItems
turns.forEach { turn ->
when (turn) {
is ChatTurn.User -> {
map[turn.message.id] = itemIndex
itemIndex++
}
is ChatTurn.Agent -> {
val hasReasoning =
turn.entries
.filterIsInstance<AgentEntry.Prose>()
.any { it.message.reasoningText.isNotBlank() }
if (hasReasoning) {
// The reasoning-<id> hoist item occupies one slot.
itemIndex++
}
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
map[entry.message.id] = itemIndex
itemIndex++
}
is AgentEntry.ToolRow -> itemIndex++
is AgentEntry.SystemEvent -> itemIndex++
}
}
}
}
}
return map
}
/**
* Resolve the message id of the CURRENT search match once, so per-item
* highlight lookups are O(1) id comparisons instead of O(n) linear scans
* (`messages.indexOfFirst` per rendered bubble was O(n²) per search update).
*/
fun currentMatchMessageId(
messages: List<ChatMessage>,
searchMatchIndices: List<Int>,
currentSearchMatchIndex: Int,
): String? {
if (currentSearchMatchIndex < 0 || currentSearchMatchIndex >= searchMatchIndices.size) return null
val messageIndex = searchMatchIndices[currentSearchMatchIndex]
if (messageIndex < 0 || messageIndex >= messages.size) return null
return messages[messageIndex].id
}
/**
* Message ids that contain at least one search match. Bubbles outside this
* set skip their highlight scan entirely (they used to re-run it on every
* search-state change even with zero hits).
*/
fun matchedMessageIds(
messages: List<ChatMessage>,
searchMatchIndices: List<Int>,
): Set<String> =
searchMatchIndices
.mapNotNull { messages.getOrNull(it)?.id }
.toSet()