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:
+12
-5
@@ -9,10 +9,14 @@ import androidx.compose.ui.test.onNodeWithTag
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
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.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -46,19 +50,22 @@ class FullBleedChatListTest {
|
|||||||
clarify: Boolean = false,
|
clarify: Boolean = false,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
|
val listState = LazyListState()
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
streamingMessage = streamingMessage,
|
streamingMessage = streamingMessage,
|
||||||
isSearchActive = false,
|
searchState = ChatSearchState(),
|
||||||
searchQuery = "",
|
|
||||||
currentSearchMatchIndex = -1,
|
|
||||||
searchMatchIndices = emptyList(),
|
|
||||||
typingEffectEnabled = false,
|
typingEffectEnabled = false,
|
||||||
typingEffectDelayMs = 30,
|
typingEffectDelayMs = 30,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingOlder = false,
|
isLoadingOlder = false,
|
||||||
isDark = false,
|
isDark = false,
|
||||||
listState = LazyListState(),
|
listState = listState,
|
||||||
|
scrollController =
|
||||||
|
ChatScrollController(
|
||||||
|
listState = listState,
|
||||||
|
scope = CoroutineScope(Dispatchers.Main.immediate),
|
||||||
|
),
|
||||||
lastAnimatedMessageId = null,
|
lastAnimatedMessageId = null,
|
||||||
onLastAnimatedMessageIdChange = {},
|
onLastAnimatedMessageIdChange = {},
|
||||||
viewModel = mockk<ChatViewModel>(relaxed = true),
|
viewModel = mockk<ChatViewModel>(relaxed = true),
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ fun ChatScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
val credentialWarning by HermesWsClient.credentialWarning.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 lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scrollScope = rememberCoroutineScope()
|
val scrollScope = rememberCoroutineScope()
|
||||||
@@ -402,9 +405,6 @@ fun ChatScreen(
|
|||||||
errorMessage = state.errorMessage,
|
errorMessage = state.errorMessage,
|
||||||
backgroundCompleteMessage = state.backgroundCompleteMessage,
|
backgroundCompleteMessage = state.backgroundCompleteMessage,
|
||||||
openError = state.openError,
|
openError = state.openError,
|
||||||
isSearchActive = state.isSearchActive,
|
|
||||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
|
||||||
searchMatchIndices = state.searchMatchIndices,
|
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest,
|
||||||
sudoPrompt = state.sudoPrompt,
|
sudoPrompt = state.sudoPrompt,
|
||||||
secretPrompt = state.secretPrompt,
|
secretPrompt = state.secretPrompt,
|
||||||
@@ -477,9 +477,9 @@ fun ChatScreen(
|
|||||||
IconButton(onClick = { viewModel.toggleSearch() }) {
|
IconButton(onClick = { viewModel.toggleSearch() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector =
|
||||||
if (state.isSearchActive) Icons.Filled.Close else Icons.Filled.Search,
|
if (searchState.isActive) Icons.Filled.Close else Icons.Filled.Search,
|
||||||
contentDescription =
|
contentDescription =
|
||||||
if (state.isSearchActive) {
|
if (searchState.isActive) {
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.chat_action_close_search,
|
R.string.chat_action_close_search,
|
||||||
)
|
)
|
||||||
@@ -519,7 +519,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isSearchActive,
|
visible = searchState.isActive,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandVertically() + fadeIn(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = shrinkVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
@@ -535,10 +535,11 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
SearchBarRow(
|
SearchBarRow(
|
||||||
searchQuery = state.searchQuery,
|
searchQuery = searchState.query,
|
||||||
onQueryChange = { viewModel.setSearchQuery(it) },
|
onQueryChange = { viewModel.setSearchQuery(it) },
|
||||||
searchMatchCount = state.searchMatchIndices.size,
|
searchMatchCount = searchState.matchTotal,
|
||||||
currentMatchIndex = state.currentSearchMatchIndex,
|
searchMatchCapped = searchState.matchCapped,
|
||||||
|
currentMatchIndex = searchState.currentIndex,
|
||||||
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
|
||||||
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
onNavigateDown = { viewModel.navigateSearchMatch(1) },
|
||||||
onClose = { viewModel.clearSearch() },
|
onClose = { viewModel.clearSearch() },
|
||||||
@@ -583,10 +584,7 @@ fun ChatScreen(
|
|||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = state.messages,
|
messages = state.messages,
|
||||||
streamingMessage = streamingState.streamingMessage,
|
streamingMessage = streamingState.streamingMessage,
|
||||||
isSearchActive = state.isSearchActive,
|
searchState = searchState,
|
||||||
searchQuery = state.searchQuery,
|
|
||||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
|
||||||
searchMatchIndices = state.searchMatchIndices,
|
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
@@ -594,6 +592,7 @@ fun ChatScreen(
|
|||||||
isLoadingOlder = state.isLoadingOlder,
|
isLoadingOlder = state.isLoadingOlder,
|
||||||
isDark = isDark,
|
isDark = isDark,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
|
scrollController = scrollController,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
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.
|
* Pure search logic for in-chat text search.
|
||||||
*
|
*
|
||||||
@@ -7,16 +24,50 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
*/
|
*/
|
||||||
class ChatSearchController {
|
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(
|
fun findMatches(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
query: String,
|
query: String,
|
||||||
): List<Int> {
|
): SearchResult {
|
||||||
if (query.isBlank()) return emptyList()
|
if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false)
|
||||||
return messages.indices.filter { idx ->
|
val result = mutableListOf<SearchMatch>()
|
||||||
messages[idx].content.contains(query, ignoreCase = true)
|
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
|
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
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds chat in-message search state and logic, extracted from [ChatViewModel]
|
* Holds chat in-message search state and logic, extracted from [ChatViewModel]
|
||||||
* to keep the god-object focused on messaging/session concerns.
|
* to keep the god-object focused on messaging/session concerns.
|
||||||
*
|
*
|
||||||
* Behavior is identical to the original inline implementation: it mutates the
|
* Search state lives in a dedicated snapshot-backed [ChatSearchState] holder
|
||||||
* shared [uiState] flow for the four search-related fields and reads `messages`
|
* (NOT in [ChatUiState]): search updates then recompose only the scopes that
|
||||||
* from it when computing matches.
|
* 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
|
* @param dispatcher The [CoroutineDispatcher] used for the (CPU-bound) search
|
||||||
* work. Defaults to [Dispatchers.Default] — the original behavior — but can
|
* work. Defaults to [Dispatchers.Default] — the original behavior — but can
|
||||||
* be injected to reuse a caller's context or customize per environment.
|
* 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(
|
class ChatSearchDelegate(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val uiState: MutableStateFlow<ChatUiState>,
|
private val uiState: MutableStateFlow<ChatUiState>,
|
||||||
|
val searchState: ChatSearchState = ChatSearchState(),
|
||||||
private val searchController: ChatSearchController = ChatSearchController(),
|
private val searchController: ChatSearchController = ChatSearchController(),
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
private val debounceMs: Long = SEARCH_DEBOUNCE_MS,
|
||||||
) {
|
) {
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
fun toggleSearch() {
|
fun toggleSearch() {
|
||||||
val current = uiState.value
|
if (searchState.isActive) {
|
||||||
if (current.isSearchActive) {
|
|
||||||
clearSearch()
|
clearSearch()
|
||||||
} else {
|
} else {
|
||||||
uiState.update { it.copy(isSearchActive = true, searchQuery = "") }
|
searchState.isActive = true
|
||||||
|
searchState.query = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSearchQuery(query: String) {
|
fun setSearchQuery(query: String) {
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
|
searchState.query = query
|
||||||
|
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
uiState.update {
|
resetMatches()
|
||||||
it.copy(
|
|
||||||
searchQuery = query,
|
|
||||||
searchMatchIndices = emptyList(),
|
|
||||||
currentSearchMatchIndex = -1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep local state in sync immediately so UI feels responsive.
|
// Keep local state in sync immediately so UI feels responsive, then
|
||||||
uiState.update {
|
// debounce: cancel any pending scan and only run one after typing
|
||||||
it.copy(searchQuery = query)
|
// pauses for debounceMs (fast typing = one scan, not one per char).
|
||||||
}
|
searchJob =
|
||||||
|
scope.launch(dispatcher) {
|
||||||
searchJob = scope.launch(dispatcher) { runSearch(query) }
|
delay(debounceMs)
|
||||||
|
runSearch(query)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runSearch(query: String) {
|
private fun runSearch(query: String) {
|
||||||
val messages = uiState.value.messages
|
val messages = uiState.value.messages
|
||||||
val indices = searchController.findMatches(messages, query)
|
val result = searchController.findMatches(messages, query)
|
||||||
|
|
||||||
uiState.update {
|
// Only update matches if the search query hasn't changed in the meantime.
|
||||||
// Only update indices if the search query hasn't changed in the meantime
|
if (searchState.query != query) return
|
||||||
if (it.searchQuery == query) {
|
|
||||||
it.copy(
|
searchState.matchIndices = result.matches.map { m -> m.messageIndex }
|
||||||
searchMatchIndices = indices,
|
searchState.matchOffsets = result.matches.map { m -> m.contentOffset }
|
||||||
currentSearchMatchIndex = if (indices.isNotEmpty()) 0 else -1,
|
searchState.matchTotal = result.totalMatches
|
||||||
)
|
searchState.matchCapped = result.capped
|
||||||
} else {
|
searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1
|
||||||
it
|
searchState.matchedIds = matchedMessageIds(messages, searchState.matchIndices)
|
||||||
}
|
searchState.currentMatchId =
|
||||||
}
|
currentMatchMessageId(messages, searchState.matchIndices, searchState.currentIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun navigateSearchMatch(direction: Int) {
|
fun navigateSearchMatch(direction: Int) {
|
||||||
uiState.update { state ->
|
val indices = searchState.matchIndices
|
||||||
val indices = state.searchMatchIndices
|
if (indices.isEmpty()) return
|
||||||
if (indices.isEmpty()) return@update state
|
val newIdx =
|
||||||
val newIdx =
|
searchController.navigate(
|
||||||
searchController.navigate(
|
currentIndex = searchState.currentIndex,
|
||||||
currentIndex = state.currentSearchMatchIndex,
|
matchCount = indices.size,
|
||||||
matchCount = indices.size,
|
direction = direction,
|
||||||
direction = direction,
|
)
|
||||||
)
|
searchState.currentIndex = newIdx
|
||||||
state.copy(currentSearchMatchIndex = newIdx)
|
searchState.currentMatchId =
|
||||||
}
|
currentMatchMessageId(uiState.value.messages, indices, newIdx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearSearch() {
|
fun clearSearch() {
|
||||||
uiState.update {
|
searchJob?.cancel()
|
||||||
it.copy(
|
searchState.isActive = false
|
||||||
isSearchActive = false,
|
searchState.query = ""
|
||||||
searchQuery = "",
|
resetMatches()
|
||||||
searchMatchIndices = emptyList(),
|
}
|
||||||
currentSearchMatchIndex = -1,
|
|
||||||
)
|
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,
|
val showSessionPicker: Boolean = false,
|
||||||
// /update confirm dialog (issue #862) — the command is handled client-side
|
// /update confirm dialog (issue #862) — the command is handled client-side
|
||||||
val updateConfirmOpen: Boolean = false,
|
val updateConfirmOpen: Boolean = false,
|
||||||
// Search state
|
// Search state lives in ChatSearchState (searchDelegate.searchState) — a
|
||||||
val isSearchActive: Boolean = false,
|
// snapshot-backed holder, so search updates don't recompose the whole UI.
|
||||||
val searchQuery: String = "",
|
|
||||||
val searchMatchIndices: List<Int> = emptyList(),
|
|
||||||
val currentSearchMatchIndex: Int = -1,
|
|
||||||
// Cached settings
|
// Cached settings
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
@@ -481,6 +478,10 @@ class ChatViewModel(
|
|||||||
uiState = _uiState,
|
uiState = _uiState,
|
||||||
dispatcher = searchDispatcher,
|
dispatcher = searchDispatcher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Snapshot-backed in-chat search state (see [ChatSearchDelegate]). */
|
||||||
|
val searchState: ChatSearchState
|
||||||
|
get() = searchDelegate.searchState
|
||||||
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
|
private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2112,10 +2113,6 @@ class ChatViewModel(
|
|||||||
sudoPrompt = null,
|
sudoPrompt = null,
|
||||||
secretPrompt = null,
|
secretPrompt = null,
|
||||||
showSessionPicker = false,
|
showSessionPicker = false,
|
||||||
isSearchActive = false,
|
|
||||||
searchQuery = "",
|
|
||||||
searchMatchIndices = emptyList(),
|
|
||||||
currentSearchMatchIndex = -1,
|
|
||||||
showModelPicker = false,
|
showModelPicker = false,
|
||||||
modelPickerLoading = false,
|
modelPickerLoading = false,
|
||||||
currentSessionModel = null,
|
currentSessionModel = null,
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ fun ChatLifecycleEffects(
|
|||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
backgroundCompleteMessage: String?,
|
backgroundCompleteMessage: String?,
|
||||||
openError: String?,
|
openError: String?,
|
||||||
isSearchActive: Boolean,
|
|
||||||
currentSearchMatchIndex: Int,
|
|
||||||
searchMatchIndices: List<Int>,
|
|
||||||
clarifyRequest: ClarifyUi?,
|
clarifyRequest: ClarifyUi?,
|
||||||
sudoPrompt: SudoPromptUi?,
|
sudoPrompt: SudoPromptUi?,
|
||||||
secretPrompt: SecretPromptUi?,
|
secretPrompt: SecretPromptUi?,
|
||||||
@@ -134,17 +131,4 @@ fun ChatLifecycleEffects(
|
|||||||
onDismiss = viewModel::dismissSecret,
|
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) }
|
scope.launch { listState.scrollToItem(index, offset) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to a search match index (serialized through [scope]). */
|
/**
|
||||||
fun scrollToSearchMatch(index: Int) {
|
* Navigate to a search match (serialized through [scope]).
|
||||||
scope.launch { listState.animateScrollToItem(index) }
|
*
|
||||||
|
* [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,
|
searchQuery: String,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
searchMatchCount: Int,
|
searchMatchCount: Int,
|
||||||
|
searchMatchCapped: Boolean = false,
|
||||||
currentMatchIndex: Int,
|
currentMatchIndex: Int,
|
||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
onNavigateDown: () -> Unit,
|
onNavigateDown: () -> Unit,
|
||||||
@@ -124,7 +125,9 @@ fun SearchBarRow(
|
|||||||
if (searchQuery.isNotEmpty()) {
|
if (searchQuery.isNotEmpty()) {
|
||||||
val countText =
|
val countText =
|
||||||
if (searchMatchCount > 0) {
|
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 {
|
} else {
|
||||||
"0/0"
|
"0/0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -18,10 +19,12 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.ui.chat.ChatBubble
|
import com.m57.hermescontrol.ui.chat.ChatBubble
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
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.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
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.ClarifyBubble
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import com.m57.hermescontrol.ui.chat.toolCallMilestones
|
import com.m57.hermescontrol.ui.chat.toolCallMilestones
|
||||||
@@ -46,10 +49,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
|
|||||||
fun FullBleedChatList(
|
fun FullBleedChatList(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: ChatMessage?,
|
streamingMessage: ChatMessage?,
|
||||||
isSearchActive: Boolean,
|
searchState: ChatSearchState,
|
||||||
searchQuery: String,
|
|
||||||
currentSearchMatchIndex: Int,
|
|
||||||
searchMatchIndices: List<Int>,
|
|
||||||
typingEffectEnabled: Boolean,
|
typingEffectEnabled: Boolean,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
maxToolCallsPerTurn: Int? = null,
|
maxToolCallsPerTurn: Int? = null,
|
||||||
@@ -57,6 +57,7 @@ fun FullBleedChatList(
|
|||||||
isLoadingOlder: Boolean,
|
isLoadingOlder: Boolean,
|
||||||
isDark: Boolean,
|
isDark: Boolean,
|
||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
scrollController: ChatScrollController,
|
||||||
lastAnimatedMessageId: String?,
|
lastAnimatedMessageId: String?,
|
||||||
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
@@ -83,6 +84,37 @@ fun FullBleedChatList(
|
|||||||
remember(messages, streamingMessage) {
|
remember(messages, streamingMessage) {
|
||||||
groupIntoTurnsWithStreaming(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(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -111,15 +143,10 @@ fun FullBleedChatList(
|
|||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
renderChatBubble(
|
renderChatBubble(
|
||||||
message = userMessage,
|
message = userMessage,
|
||||||
searchQuery = searchQuery,
|
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||||
isCurrentMatch =
|
isCurrentMatch =
|
||||||
isCurrentMatchFor(
|
searchState.currentMatchId != null &&
|
||||||
messages,
|
searchState.currentMatchId == userMessage.id,
|
||||||
userMessage.id,
|
|
||||||
isSearchActive,
|
|
||||||
currentSearchMatchIndex,
|
|
||||||
searchMatchIndices,
|
|
||||||
),
|
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
@@ -181,15 +208,19 @@ fun FullBleedChatList(
|
|||||||
message = proseMessage,
|
message = proseMessage,
|
||||||
showTurnHeader = showTurnHeader,
|
showTurnHeader = showTurnHeader,
|
||||||
isDarkTheme = isDark,
|
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 =
|
isCurrentMatch =
|
||||||
isCurrentMatchFor(
|
searchState.currentMatchId != null &&
|
||||||
messages,
|
searchState.currentMatchId == proseMessage.id,
|
||||||
proseMessage.id,
|
|
||||||
isSearchActive,
|
|
||||||
currentSearchMatchIndex,
|
|
||||||
searchMatchIndices,
|
|
||||||
),
|
|
||||||
showReasoning = !hoistedReasoning,
|
showReasoning = !hoistedReasoning,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
@@ -275,18 +306,3 @@ private fun renderChatBubble(
|
|||||||
onImageClick = onImageClick,
|
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
|
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()
|
||||||
|
|||||||
@@ -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()
|
val viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertFalse(viewModel.uiState.value.isSearchActive)
|
assertFalse(viewModel.searchState.isActive)
|
||||||
|
|
||||||
viewModel.toggleSearch()
|
viewModel.toggleSearch()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertTrue(viewModel.uiState.value.isSearchActive)
|
assertTrue(viewModel.searchState.isActive)
|
||||||
|
|
||||||
viewModel.setSearchQuery("test")
|
viewModel.setSearchQuery("test")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -2744,8 +2744,8 @@ class ChatViewModelTest {
|
|||||||
viewModel.toggleSearch()
|
viewModel.toggleSearch()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertFalse(viewModel.uiState.value.isSearchActive)
|
assertFalse(viewModel.searchState.isActive)
|
||||||
assertEquals("", viewModel.uiState.value.searchQuery)
|
assertEquals("", viewModel.searchState.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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.AgentEntry
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
|
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.groupIntoTurns
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurnsWithStreaming
|
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.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class FullBleedTurnsTest {
|
class FullBleedTurnsTest {
|
||||||
@@ -145,4 +149,63 @@ class FullBleedTurnsTest {
|
|||||||
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
|
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
|
||||||
assertEquals(listOf(entries(AgentEntry.Prose(s))), groupIntoTurnsWithStreaming(emptyList(), s))
|
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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user