fix(chat): searchable tool/reasoning rows and live markdown (#1361)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 19:31:43 +03:00
1 parent 580f3b9697
commit 85903afd38
19 files changed
+454 -146

No files matched your search

@@ -1,5 +1,38 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
/** The rendered transcript surface where a search occurrence belongs. */
enum class SearchTarget {
CONTENT,
REASONING,
TOOL,
}
/** Search only the tool header and collapsed summary; raw payload is hidden until expanded. */
internal fun searchableToolText(message: ChatMessage): String {
val view = ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning)
val summary = view?.let { composeToolSummaryLines(it, message.toolName) }
return listOfNotNull(
(view?.serverDisplayName ?: message.toolName)?.takeIf { it.isNotBlank() },
summary?.first,
summary?.second,
message.progressPreview?.takeIf { message.isToolRunning && it.isNotBlank() },
).joinToString("\n")
}
internal fun searchTextFor(
message: ChatMessage,
target: SearchTarget,
): String =
when (target) {
SearchTarget.CONTENT -> message.content
SearchTarget.REASONING -> message.reasoningText
SearchTarget.TOOL -> searchableToolText(message)
}
/**
* 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
@@ -8,6 +41,7 @@ package com.m57.hermescontrol.ui.chat
data class SearchMatch(
val messageIndex: Int,
val contentOffset: Int,
val target: SearchTarget = SearchTarget.CONTENT,
)
/** Result of a search scan, including the exact total before any cap. */
@@ -24,28 +58,11 @@ data class SearchResult(
*/
class ChatSearchController {
/**
* 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.
* Find literal occurrences in rendered user/assistant prose, reasoning,
* and the visible tool header/summary. Never scan hidden raw tool payloads.
* Offsets are relative to each target surface, not the whole message.
* Scans the original strings so case folding cannot shift offsets; stores
* at most [MAX_SEARCH_MATCHES] hits while retaining the exact total.
*/
fun findMatches(
messages: List<ChatMessage>,
@@ -54,19 +71,43 @@ class ChatSearchController {
if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false)
val result = mutableListOf<SearchMatch>()
var total = 0
for ((idx, message) in messages.withIndex()) {
if (message.role != MessageRole.USER && message.role != MessageRole.ASSISTANT) continue
fun scan(
index: Int,
text: String,
target: SearchTarget,
) {
var from = 0
while (true) {
val hit = message.content.indexOf(query, from, ignoreCase = true)
val hit = text.indexOf(query, from, ignoreCase = true)
if (hit < 0) break
total++
if (result.size < MAX_SEARCH_MATCHES) {
result.add(SearchMatch(idx, hit))
}
if (result.size < MAX_SEARCH_MATCHES) result.add(SearchMatch(index, hit, target))
from = hit + query.length
}
}
for ((idx, message) in messages.withIndex()) {
when (message.role) {
MessageRole.USER -> {
if (!message.isTimelineMarker() && !message.isSyntheticSystemRow()) {
scan(idx, message.content, SearchTarget.CONTENT)
}
}
MessageRole.ASSISTANT -> {
if (message.reasoningText.isNotBlank()) scan(idx, message.reasoningText, SearchTarget.REASONING)
if (message.content.isNotBlank()) scan(idx, message.content, SearchTarget.CONTENT)
}
MessageRole.TOOL -> {
scan(idx, searchableToolText(message), SearchTarget.TOOL)
}
MessageRole.SYSTEM -> {
// System rows are not part of in-chat search.
}
}
}
return SearchResult(result, total, capped = total > MAX_SEARCH_MATCHES)
}
@@ -74,6 +74,7 @@ class ChatSearchDelegate(
searchState.matchIndices = result.matches.map { m -> m.messageIndex }
searchState.matchOffsets = result.matches.map { m -> m.contentOffset }
searchState.matchTargets = result.matches.map { m -> m.target }
searchState.matchTotal = result.totalMatches
searchState.matchCapped = result.capped
searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1
@@ -106,6 +107,7 @@ class ChatSearchDelegate(
private fun resetMatches() {
searchState.matchIndices = emptyList()
searchState.matchOffsets = emptyList()
searchState.matchTargets = emptyList()
searchState.matchTotal = 0
searchState.matchCapped = false
searchState.currentIndex = -1
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
/** Highlight literal search hits in plain transcript labels, reasoning, and tool summaries. */
internal fun buildHighlightedString(
text: String,
query: String,
isCurrentMatch: Boolean = false,
statusColors: HermesStatusColors,
): AnnotatedString {
if (query.isEmpty()) return AnnotatedString(text)
val (highlightColor, highlightText) =
if (isCurrentMatch) {
statusColors.warning to statusColors.onWarning
} else {
statusColors.warningContainer to onColorFor(statusColors.warningContainer)
}
return buildAnnotatedString {
var i = 0
while (i < text.length) {
val matchStart = text.indexOf(query, i, ignoreCase = true)
if (matchStart == -1) {
append(text.substring(i))
break
}
append(text.substring(i, matchStart))
withStyle(SpanStyle(background = highlightColor, color = highlightText)) {
append(text.substring(matchStart, matchStart + query.length))
}
i = matchStart + query.length
}
}
}
@@ -34,6 +34,10 @@ class ChatSearchState {
var matchOffsets by mutableStateOf(emptyList<Int>())
internal set
/** Parallel to [matchIndices]: rendered surface for each occurrence. */
var matchTargets by mutableStateOf(emptyList<SearchTarget>())
internal set
var currentIndex by mutableStateOf(-1)
internal set
@@ -78,9 +78,9 @@ private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr
/**
* Renders chat assistant text as Markdown — but ONLY once the message has finished streaming.
* While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap
* to the formatted view on completion (and for all historical/restored messages).
* Renders chat assistant text as Markdown, including while it streams. The
* upstream token buffer coalesces deltas; parsing is memoized for identical text
* so completion does not swap a plain-text row for a differently sized block tree.
*
* Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*,
* ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, <kbd>keys</kbd>, headings,
@@ -99,25 +99,8 @@ fun MarkdownText(
) {
val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors)
if (isStreaming) {
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) {
Text(
text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text,
color = textColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
modifier = modifier,
)
}
return
}
val linkColor = MaterialTheme.colorScheme.primary
val blocks = remember(text) { parseBlocks(text) }
val blocks = remember(text, isStreaming) { parseBlocks(text) }
val latexMeasurer = rememberLatexMeasurer()
Column(modifier = modifier.fillMaxWidth()) {
@@ -125,6 +125,8 @@ internal fun composeToolSummaryLines(
internal fun ToolBubble(
message: ChatMessage,
modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) {
var expanded by remember { mutableStateOf(false) }
var showRawJson by remember { mutableStateOf(false) }
@@ -169,12 +171,26 @@ internal fun ToolBubble(
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
// ── Header row: icon + tool name ──
ToolBubbleHeader(message, config, contentColor, statusColors, view?.serverDisplayName)
ToolBubbleHeader(
message = message,
config = config,
contentColor = contentColor,
statusColors = statusColors,
displayName = view?.serverDisplayName,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
)
// ── Tool progress preview (tool.progress) ──
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
Text(
text = message.progressPreview,
text =
buildHighlightedString(
message.progressPreview,
searchQuery,
isCurrentMatch,
statusColors,
),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
@@ -208,7 +224,7 @@ internal fun ToolBubble(
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = firstLine,
text = buildHighlightedString(firstLine, searchQuery, isCurrentMatch, statusColors),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
@@ -221,7 +237,7 @@ internal fun ToolBubble(
}
if (secondLine != null) {
Text(
text = secondLine,
text = buildHighlightedString(secondLine, searchQuery, isCurrentMatch, statusColors),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.5f),
@@ -32,6 +32,8 @@ internal fun ToolBubbleHeader(
contentColor: Color,
statusColors: HermesStatusColors,
displayName: String? = null,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -66,7 +68,13 @@ internal fun ToolBubbleHeader(
}
Text(
text = displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
text =
buildHighlightedString(
displayName ?: message.toolName ?: stringResource(R.string.chat_tool_fallback),
searchQuery,
isCurrentMatch,
statusColors,
),
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
@@ -49,19 +49,14 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.theme.DarkOnSurface
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.launch
@@ -255,41 +250,3 @@ fun UserBubble(
}
}
}
/**
* Build an AnnotatedString with search matches highlighted.
*/
private fun buildHighlightedString(
text: String,
query: String,
isCurrentMatch: Boolean = false,
statusColors: HermesStatusColors,
): AnnotatedString {
val (highlightColor, highlightText) =
if (isCurrentMatch) {
statusColors.warning to statusColors.onWarning
} else {
statusColors.warningContainer to onColorFor(statusColors.warningContainer)
}
return buildAnnotatedString {
var i = 0
while (i < text.length) {
val matchEnd = text.indexOf(query, i, ignoreCase = true)
if (matchEnd == -1) {
// No more matches — append the rest
append(text.substring(i))
i = text.length
} else {
// Append text before the match
if (matchEnd > i) {
append(text.substring(i, matchEnd))
}
// Append the match highlighted
withStyle(SpanStyle(background = highlightColor, color = highlightText)) {
append(text.substring(matchEnd, matchEnd + query.length))
}
i = matchEnd + query.length
}
}
}
}
@@ -38,6 +38,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -54,7 +55,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.buildHighlightedString
/**
* Collapsible card showing the assistant's reasoning trace
@@ -73,13 +76,25 @@ fun ReasoningCard(
reasoningText: String,
isStreaming: Boolean = false,
modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
searchOffset: Int = 0,
) {
val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(isCurrentMatch, searchQuery) {
if (isCurrentMatch && searchQuery.isNotBlank()) expanded = true
}
var fullHeight by remember { mutableStateOf(false) }
// Copy feedback: briefly show "Copied" then revert
var copied by rememberCopyFeedback()
val scrollState = rememberScrollState()
LaunchedEffect(expanded, isCurrentMatch, searchQuery, searchOffset, scrollState.maxValue) {
if (expanded && isCurrentMatch && searchQuery.isNotBlank() && scrollState.maxValue > 0) {
val fraction = searchOffset.toFloat() / reasoningText.length.coerceAtLeast(1)
scrollState.scrollTo((scrollState.maxValue * fraction).toInt())
}
}
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp }
@@ -150,8 +165,9 @@ fun ReasoningCard(
},
)
if (isStreaming) {
val statusColors = LocalHermesStatusColors.current
Text(
text = reasoningText,
text = buildHighlightedString(reasoningText, searchQuery, isCurrentMatch, statusColors),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = contentModifier,
@@ -160,6 +176,8 @@ fun ReasoningCard(
MarkdownText(
text = reasoningText,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
modifier = contentModifier,
)
}
@@ -0,0 +1,42 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.searchTextFor
/** Resolve a search hit against emitted lazy rows, including hoisted reasoning and tool rows. */
@Composable
internal fun ChatSearchScrollEffect(
searchState: ChatSearchState,
messages: List<ChatMessage>,
renderedFirstId: String?,
turns: List<ChatTurn>,
scrollController: ChatScrollController,
) {
LaunchedEffect(
searchState.isActive,
searchState.currentIndex,
searchState.matchIndices,
searchState.matchOffsets,
searchState.matchTargets,
renderedFirstId,
) {
val index = searchState.currentIndex
if (!searchState.isActive || index !in searchState.matchIndices.indices) return@LaunchedEffect
val messageIndex = searchState.matchIndices[index]
val message = messages.getOrNull(messageIndex) ?: return@LaunchedEffect
val target = searchState.matchTargets.getOrNull(index) ?: return@LaunchedEffect
val match =
com.m57.hermescontrol.ui.chat.SearchMatch(
messageIndex = messageIndex,
contentOffset = searchState.matchOffsets.getOrElse(index) { 0 },
target = target,
)
// A hit in a staged history prefix is retried when the prefix enters layout.
val lazyIndex = searchMatchToLazyIndex(turns, messages, match) ?: return@LaunchedEffect
scrollController.scrollToSearchMatch(lazyIndex, match.contentOffset, searchTextFor(message, target).length)
}
}
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.SearchTarget
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.UserBubble
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
@@ -260,32 +261,13 @@ fun FullBleedChatList(
}
}
// 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,
renderedMessages.firstOrNull()?.id,
) {
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
// Search indices refer to incoming messages; resolve by id into the
// rendered rows. A hit in a staged prefix is retried when it becomes visible.
val lazyIndexById = messageIdToLazyIndex(turns)
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)
}
}
ChatSearchScrollEffect(
searchState = searchState,
messages = messages,
renderedFirstId = renderedMessages.firstOrNull()?.id,
turns = turns,
scrollController = scrollController,
)
val currentDensity = LocalDensity.current
val chatFontScale = LocalChatFontScale.current
@@ -352,6 +334,22 @@ fun FullBleedChatList(
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
searchQuery =
if (reasoning.id in
searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId == reasoning.id &&
searchState.matchTargets.getOrNull(searchState.currentIndex) ==
SearchTarget.REASONING,
searchOffset =
searchState.matchOffsets.getOrElse(
searchState.currentIndex,
) { 0 },
)
}
}
@@ -373,6 +371,22 @@ fun FullBleedChatList(
ReasoningCard(
reasoningText = proseMessage.reasoningText,
isStreaming = proseMessage.isStreaming,
searchQuery =
if (proseMessage.id in searchState.matchedIds) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId == proseMessage.id &&
searchState.matchTargets.getOrNull(
searchState.currentIndex,
) ==
SearchTarget.REASONING,
searchOffset =
searchState.matchOffsets.getOrElse(
searchState.currentIndex,
) { 0 },
)
}
}
@@ -407,8 +421,11 @@ fun FullBleedChatList(
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
searchState.currentMatchId == proseMessage.id &&
searchState.matchTargets.getOrNull(
searchState.currentIndex,
) ==
SearchTarget.CONTENT,
showReasoning = !hoistedReasoning,
onOpenAttachment = actions.onOpenAttachment,
onSaveAttachment = actions.onSaveAttachment,
@@ -436,7 +453,21 @@ fun FullBleedChatList(
contentType = FullBleedContentType.TOOL,
) {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedToolRow(toolMessage)
FullBleedToolRow(
message = toolMessage,
searchQuery =
if (toolMessage.id in searchState.matchedIds) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId == toolMessage.id &&
searchState.matchTargets.getOrNull(
searchState.currentIndex,
) ==
SearchTarget.TOOL,
)
milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
@@ -24,6 +24,8 @@ import com.m57.hermescontrol.ui.chat.ToolBubble
internal fun FullBleedToolRow(
message: ChatMessage,
modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) {
Box(
modifier =
@@ -32,7 +34,7 @@ internal fun FullBleedToolRow(
.padding(start = 16.dp, end = 16.dp)
.testTag("fullbleed_tool_row"),
) {
ToolBubble(message)
ToolBubble(message, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch)
}
}
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SearchMatch
import com.m57.hermescontrol.ui.chat.SearchTarget
/**
* Turn model for the full-bleed chat renderer (issue #866).
@@ -52,7 +54,7 @@ internal fun ChatMessage.hasVisibleAgentContent(): Boolean = content.isNotBlank(
private const val MAX_ITERATIONS_SYSTEM_MARKER =
"You've reached the maximum number of tool-calling iterations allowed."
private fun ChatMessage.isSyntheticSystemRow(): Boolean =
internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
role == MessageRole.USER &&
displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
@@ -186,6 +188,21 @@ fun messageIdToLazyIndex(
}
}.toMap()
fun searchMatchToLazyIndex(
turns: List<ChatTurn>,
messages: List<ChatMessage>,
match: SearchMatch,
): Int? {
val message = messages.getOrNull(match.messageIndex) ?: return null
val prefix =
when (match.target) {
SearchTarget.CONTENT -> if (message.role == MessageRole.USER) "user" else "prose"
SearchTarget.REASONING -> "reasoning"
SearchTarget.TOOL -> "tool"
}
return fullBleedItemKeys(turns).indexOf("$prefix-${message.id}").takeIf { it >= 0 }
}
/** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
buildList {