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

@@ -0,0 +1,179 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSearchControllerTest {
private val controller = ChatSearchController()
private fun message(
role: MessageRole,
content: String,
reasoningText: String = "",
): ChatMessage = ChatMessage(role = role, content = content, reasoningText = reasoningText)
private fun hits(result: SearchResult) = result.matches
@Test
fun `matches user and assistant visible text with content offsets`() {
val messages =
listOf(
message(MessageRole.USER, "deploy the API"),
message(MessageRole.ASSISTANT, "Let's deploy it now"),
message(MessageRole.USER, "unrelated"),
)
assertEquals(
listOf(SearchMatch(0, 0), SearchMatch(1, 6)),
hits(controller.findMatches(messages, "deploy")),
)
assertEquals(2, controller.findMatches(messages, "deploy").totalMatches)
assertFalse(controller.findMatches(messages, "deploy").capped)
}
@Test
fun `excludes tool rows even when the payload contains the query`() {
val messages =
listOf(
message(MessageRole.USER, "check the logs"),
message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"),
message(MessageRole.ASSISTANT, "all good"),
)
assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "check the logs")))
}
@Test
fun `excludes system events`() {
val messages =
listOf(
message(MessageRole.USER, "approve the terminal command"),
message(MessageRole.SYSTEM, "Approval requested: approve the terminal command"),
)
assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "approve")))
}
@Test
fun `excludes reasoning text that never made it to visible content`() {
val messages =
listOf(
message(
role = MessageRole.ASSISTANT,
content = "Here is the answer",
reasoningText = "thinking about the deployment steps",
),
)
// Only the visible content is searchable — reasoning is invisible.
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "deployment")))
assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer")))
}
@Test
fun `matching is case insensitive`() {
val messages = listOf(message(MessageRole.USER, "Deploy the API now"))
assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "dEpLoY")))
}
@Test
fun `repeats the message index once per occurrence with each offset`() {
val messages = listOf(message(MessageRole.USER, "deploy deploy and deploy"))
// 3 hits in one message = 3 entries with their own character offsets.
assertEquals(
listOf(SearchMatch(0, 0), SearchMatch(0, 7), SearchMatch(0, 18)),
hits(controller.findMatches(messages, "deploy")),
)
}
@Test
fun `counts occurrences across multiple messages`() {
val messages =
listOf(
message(MessageRole.USER, "deploy the api"),
message(MessageRole.ASSISTANT, "deploy deploy"),
)
assertEquals(
listOf(SearchMatch(0, 0), SearchMatch(1, 0), SearchMatch(1, 7)),
hits(controller.findMatches(messages, "deploy")),
)
}
@Test
fun `regex special characters in the query are treated literally`() {
val messages = listOf(message(MessageRole.USER, "a+b c a+b"))
assertEquals(
listOf(SearchMatch(0, 0), SearchMatch(0, 6)),
hits(controller.findMatches(messages, "a+b")),
)
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "a.")))
}
@Test
fun `blank query returns no matches`() {
val messages = listOf(message(MessageRole.USER, "deploy"))
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, " ")))
}
@Test
fun `matches every occurrence in a long message with correct offsets`() {
val content = "deploy ".repeat(200) + "deploy"
val m = message(MessageRole.USER, content)
val result = controller.findMatches(listOf(m), "deploy")
assertEquals(201, result.matches.size)
assertEquals(201, result.totalMatches)
assertEquals(0, result.matches.first().contentOffset)
assertEquals(1400, result.matches.last().contentOffset)
assertFalse(result.capped)
}
@Test
fun `case-insensitive match keeps exact offset on the original content`() {
val m = message(MessageRole.USER, "Shout LOUDLY: DePlOy!")
assertEquals(listOf(SearchMatch(0, 14)), hits(controller.findMatches(listOf(m), "deploy")))
}
@Test
fun `caps matches at MAX_SEARCH_MATCHES but keeps the exact total`() {
val content = "a ".repeat(2_000)
val m = message(MessageRole.USER, content)
val result = controller.findMatches(listOf(m), "a")
assertEquals(ChatSearchController.MAX_SEARCH_MATCHES, result.matches.size)
assertEquals(2_000, result.totalMatches)
assertTrue(result.capped)
// First and last stored entries are real hits with valid offsets.
assertEquals(0, result.matches.first().contentOffset)
assertTrue(result.matches.last().contentOffset >= 0)
}
@Test
fun `caps across multiple messages counting the exact total`() {
val messages =
List(300) { message(MessageRole.USER, "x ") } + // 300 × 1 hit = 300
List(300) { message(MessageRole.USER, "x x") } // 300 × 2 hits = 600
val result = controller.findMatches(messages, "x")
assertEquals(ChatSearchController.MAX_SEARCH_MATCHES, result.matches.size)
assertEquals(900, result.totalMatches)
assertTrue(result.capped)
}
@Test
fun `navigate wraps forward and backward`() {
assertEquals(1, controller.navigate(currentIndex = 0, matchCount = 3, direction = 1))
assertEquals(0, controller.navigate(currentIndex = 2, matchCount = 3, direction = 1))
assertEquals(2, controller.navigate(currentIndex = 0, matchCount = 3, direction = -1))
assertEquals(1, controller.navigate(currentIndex = 2, matchCount = 3, direction = -1))
assertEquals(-1, controller.navigate(currentIndex = -1, matchCount = 0, direction = 1))
}
}
@@ -0,0 +1,171 @@
package com.m57.hermescontrol.ui.chat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatSearchDelegateTest {
private fun stateWith(vararg contents: String): MutableStateFlow<ChatUiState> =
MutableStateFlow(
ChatUiState(
messages =
contents.mapIndexed { index, content ->
ChatMessage(
id = "m$index",
role = MessageRole.USER,
content = content,
)
},
),
)
@Test
fun `typing pauses coalesce into a single search for the final query`() =
runTest {
val uiState = stateWith("alpha beta", "nothing here")
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.setSearchQuery("alp")
advanceTimeBy(50)
delegate.setSearchQuery("al")
advanceTimeBy(50)
delegate.setSearchQuery("alpha")
// Debounce window not closed yet — no scan has run.
advanceTimeBy(149)
runCurrent()
assertEquals(emptyList<Int>(), delegate.searchState.matchIndices)
// Typing paused long enough — exactly one scan, for the final query.
advanceTimeBy(1)
runCurrent()
assertEquals(listOf(0), delegate.searchState.matchIndices)
assertEquals(listOf(0), delegate.searchState.matchOffsets)
assertEquals(1, delegate.searchState.matchTotal)
assertFalse(delegate.searchState.matchCapped)
assertEquals("alpha", delegate.searchState.query)
assertEquals(setOf("m0"), delegate.searchState.matchedIds)
assertEquals("m0", delegate.searchState.currentMatchId)
assertEquals(0, delegate.searchState.currentIndex)
}
@Test
fun `cancelled intermediate queries never produce matches`() =
runTest {
val uiState = stateWith("alpha one", "beta two")
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.setSearchQuery("alpha")
advanceTimeBy(100)
// New keystroke cancels the pending "alpha" scan before it fires.
delegate.setSearchQuery("beta")
advanceTimeBy(150)
runCurrent()
// Only "beta" ever ran — a stale "alpha" scan must not land.
assertEquals(listOf(1), delegate.searchState.matchIndices)
assertEquals("beta", delegate.searchState.query)
assertEquals("m1", delegate.searchState.currentMatchId)
}
@Test
fun `blank query clears matches immediately without waiting for debounce`() =
runTest {
val uiState = stateWith("alpha one")
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.setSearchQuery("alpha")
advanceTimeBy(150)
runCurrent()
assertEquals(listOf(0), delegate.searchState.matchIndices)
delegate.setSearchQuery("")
assertEquals(emptyList<Int>(), delegate.searchState.matchIndices)
assertEquals(-1, delegate.searchState.currentIndex)
assertNull(delegate.searchState.currentMatchId)
assertEquals("", delegate.searchState.query)
}
@Test
fun `navigation updates currentIndex and the current match id`() =
runTest {
val uiState = stateWith("alpha alpha alpha")
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.setSearchQuery("alpha")
advanceTimeBy(150)
runCurrent()
assertEquals(3, delegate.searchState.matchIndices.size)
assertEquals("m0", delegate.searchState.currentMatchId)
delegate.navigateSearchMatch(1)
assertEquals(1, delegate.searchState.currentIndex)
assertEquals("m0", delegate.searchState.currentMatchId)
delegate.navigateSearchMatch(-1)
assertEquals(0, delegate.searchState.currentIndex)
}
@Test
fun `clearSearch resets everything including matched sets`() =
runTest {
val uiState = stateWith("alpha one")
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.toggleSearch()
delegate.setSearchQuery("alpha")
advanceTimeBy(150)
runCurrent()
assertTrue(delegate.searchState.isActive)
assertEquals(setOf("m0"), delegate.searchState.matchedIds)
delegate.clearSearch()
assertFalse(delegate.searchState.isActive)
assertEquals("", delegate.searchState.query)
assertEquals(emptyList<Int>(), delegate.searchState.matchIndices)
assertEquals(0, delegate.searchState.matchTotal)
assertFalse(delegate.searchState.matchCapped)
assertEquals(-1, delegate.searchState.currentIndex)
assertEquals(emptySet<String>(), delegate.searchState.matchedIds)
assertNull(delegate.searchState.currentMatchId)
}
}
@@ -0,0 +1,91 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* End-to-end sanity on a synthetic LONG session (2000 messages, ~1 KB each).
*
* The matcher must stay correct at scale: exact occurrence counts, valid
* offsets, cap semantics. No wall-clock asserts (flaky on CI) — the local
* scan duration is measured manually during development.
*/
class ChatSearchPerfSanityTest {
private fun longSession(): List<ChatMessage> =
(0 until 2_000).map { index ->
val role = if (index % 2 == 0) MessageRole.USER else MessageRole.ASSISTANT
val words =
buildList {
repeat(80) { add(listOf("alpha", "beta", "gamma", "deploy", "config")[it % 5]) }
add("needle-$index")
}
ChatMessage(
id = "m$index",
role = role,
content = words.joinToString(" "),
)
}
@Test
fun `common word across a long session reports the exact total and caps storage`() {
val messages = longSession()
val result = ChatSearchController().findMatches(messages, "the")
// "the" never appears (content is alpha/beta/...), so no matches.
assertEquals(0, result.totalMatches)
assertEquals(0, result.matches.size)
assertFalse(result.capped)
}
@Test
fun `long session search returns exact counts for a frequent word`() {
val messages = longSession()
val result = ChatSearchController().findMatches(messages, "deploy")
// "deploy" is the 4th of 5 rotating words → appears 16× per message,
// 2000 messages → 32_000 total occurrences.
assertEquals(32_000, result.totalMatches)
assertTrue(result.capped)
assertEquals(ChatSearchController.MAX_SEARCH_MATCHES, result.matches.size)
// Every stored offset points at an actual "deploy" in its message.
for (match in result.matches) {
val message = messages[match.messageIndex]
val start = match.contentOffset
assertTrue(start in 0..message.content.length - 6)
assertTrue(
message.content.substring(start, start + 6).equals("deploy", ignoreCase = true),
)
}
}
@Test
fun `long session search finds a unique word at scale`() {
val messages = longSession()
val result = ChatSearchController().findMatches(messages, "needle-1999")
assertEquals(1, result.totalMatches)
assertEquals(1, result.matches.size)
assertFalse(result.capped)
val match = result.matches.single()
assertEquals(1999, match.messageIndex)
assertTrue(match.contentOffset >= 0)
}
@Test
fun `tool-heavy long session excludes payload noise`() {
val messages =
longSession().mapIndexed { index, m ->
if (index % 10 == 0) {
m.copy(role = MessageRole.TOOL, content = "tool payload containing needle-1999 repeated")
} else {
m
}
}
// The only real visible hit is the assistant message's own needle.
val result = ChatSearchController().findMatches(messages, "needle-1999")
assertEquals(1, result.totalMatches)
assertEquals(1999, result.matches.single().messageIndex)
}
}
@@ -2731,12 +2731,12 @@ class ChatViewModelTest {
val viewModel = createViewModel()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isSearchActive)
assertFalse(viewModel.searchState.isActive)
viewModel.toggleSearch()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSearchActive)
assertTrue(viewModel.searchState.isActive)
viewModel.setSearchQuery("test")
advanceUntilIdle()
@@ -2744,8 +2744,8 @@ class ChatViewModelTest {
viewModel.toggleSearch()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isSearchActive)
assertEquals("", viewModel.uiState.value.searchQuery)
assertFalse(viewModel.searchState.isActive)
assertEquals("", viewModel.searchState.query)
}
@Test
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.AgentEntry
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
import com.m57.hermescontrol.ui.chat.fullbleed.currentMatchMessageId
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurnsWithStreaming
import com.m57.hermescontrol.ui.chat.fullbleed.matchedMessageIds
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FullBleedTurnsTest {
@@ -145,4 +149,63 @@ class FullBleedTurnsTest {
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
assertEquals(listOf(entries(AgentEntry.Prose(s))), groupIntoTurnsWithStreaming(emptyList(), s))
}
// ── messageIdToLazyIndex ───────────────────────────────────────────────
@Test
fun `lazy index maps simple user turns one to one`() {
val u1 = msg("u1", MessageRole.USER)
val u2 = msg("u2", MessageRole.USER)
val map = messageIdToLazyIndex(groupIntoTurns(listOf(u1, u2)))
assertEquals(2, map.size)
assertEquals(0, map["u1"])
assertEquals(1, map["u2"])
}
@Test
fun `lazy index accounts for the reasoning hoist item`() {
val a1 = ChatMessage(id = "a1", role = MessageRole.ASSISTANT, content = "prose", reasoningText = "thinking")
val t1 = msg("t1", MessageRole.TOOL)
val a2 = msg("a2", MessageRole.ASSISTANT)
val map = messageIdToLazyIndex(groupIntoTurns(listOf(a1, t1, a2)))
// reasoning-a1 item at lazy 0, prose a1 at 1, tool t1 at 2, prose a2 at 3
assertEquals(1, map["a1"])
assertEquals(3, map["a2"])
// tool rows are items but never map (not searchable).
assertNull(map["t1"])
}
@Test
fun `lazy index offsets by leading items`() {
val u1 = msg("u1", MessageRole.USER)
val u2 = msg("u2", MessageRole.USER)
// loading-older spinner occupies lazy item 0.
val map = messageIdToLazyIndex(groupIntoTurns(listOf(u1, u2)), leadingItems = 1)
assertEquals(1, map["u1"])
assertEquals(2, map["u2"])
}
// ── currentMatchMessageId / matchedMessageIds ─────────────────────────
@Test
fun `currentMatchMessageId resolves once for the current index`() {
val u1 = msg("u1", MessageRole.USER)
val a1 = msg("a1", MessageRole.ASSISTANT)
val msgs = listOf(u1, a1)
assertEquals("a1", currentMatchMessageId(msgs, listOf(0, 1), 1))
assertEquals("u1", currentMatchMessageId(msgs, listOf(0, 1), 0))
assertNull(currentMatchMessageId(msgs, listOf(0, 1), -1))
assertNull(currentMatchMessageId(msgs, emptyList(), 0))
assertNull(currentMatchMessageId(msgs, listOf(9), 0))
}
@Test
fun `matchedMessageIds returns unique ids for match indices`() {
val u1 = msg("u1", MessageRole.USER)
val a1 = msg("a1", MessageRole.ASSISTANT)
val a2 = msg("a2", MessageRole.ASSISTANT)
val msgs = listOf(u1, a1, a2)
assertEquals(setOf("u1", "a2"), matchedMessageIds(msgs, listOf(0, 2, 0)))
assertEquals(emptySet<String>(), matchedMessageIds(msgs, listOf(9)))
}
}