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

This commit is contained in:
m57
2026-09-28 19:31:43 +03:00
committed by GitHub
parent 580f3b9697
commit 85903afd38
19 changed files with 454 additions and 146 deletions
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ChatSearchRenderingTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun reasoningMatchExpandsCollapsedCardAndShowsTheHit() {
composeTestRule.setContent {
MaterialTheme {
ReasoningCard(
reasoningText = "a hidden needle in the trace",
searchQuery = "needle",
isCurrentMatch = true,
)
}
}
composeTestRule.onNodeWithText("a hidden needle in the trace").assertIsDisplayed()
}
@Test
fun toolMatchRemainsVisibleInCollapsedHeader() {
composeTestRule.setContent {
MaterialTheme {
ToolBubble(
message = ChatMessage(role = MessageRole.TOOL, content = "opaque", toolName = "terminal"),
searchQuery = "terminal",
isCurrentMatch = true,
)
}
}
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
}
}
@@ -61,4 +61,21 @@ class MarkdownHeadingRenderingTest {
composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed()
} }
@Test
fun streamingMarkdownRendersBlocksBeforeCompletion() {
composeTestRule.setContent {
MaterialTheme {
MarkdownText(
text = "## Live heading\n\n**ready**\n\n```kotlin\nval x = 1\n```",
textColor = MaterialTheme.colorScheme.onSurface,
isStreaming = true,
)
}
}
composeTestRule.onNodeWithText("Live heading").assertIsDisplayed()
composeTestRule.onNodeWithText("ready").assertIsDisplayed()
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(1)
}
} }
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
@@ -25,11 +26,11 @@ import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatTimelineState import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SearchTarget
import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import kotlinx.coroutines.CoroutineScope import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import kotlinx.coroutines.Dispatchers
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -78,17 +79,13 @@ class FullBleedChatListTest {
showUserMessageTokens: Boolean = true, showUserMessageTokens: Boolean = true,
showAssistantMessageTokens: Boolean = true, showAssistantMessageTokens: Boolean = true,
showTokensPerSecond: Boolean = true, showTokensPerSecond: Boolean = true,
searchState: ChatSearchState = ChatSearchState(),
onController: (ChatScrollController) -> Unit = {}, onController: (ChatScrollController) -> Unit = {},
) { ) {
composeTestRule.setContent { composeTestRule.setContent {
val listState = remember { LazyListState() } val listState = remember { LazyListState() }
val scrollController = // Frame-clocked scope, matching ChatScreen: animated scrolls need a MonotonicFrameClock.
remember(listState) { val scrollController = rememberChatScrollController(listState, rememberCoroutineScope())
ChatScrollController(
listState = listState,
scope = CoroutineScope(Dispatchers.Main.immediate),
)
}
SideEffect { onController(scrollController) } SideEffect { onController(scrollController) }
FullBleedChatList( FullBleedChatList(
transcript = transcript =
@@ -119,7 +116,7 @@ class FullBleedChatListTest {
speakingMessageId = null, speakingMessageId = null,
), ),
actions = testTranscriptActions(), actions = testTranscriptActions(),
searchState = ChatSearchState(), searchState = searchState,
listState = listState, listState = listState,
scrollController = scrollController, scrollController = scrollController,
) )
@@ -139,6 +136,30 @@ class FullBleedChatListTest {
composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) } composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) }
} }
@Test
fun searchNavigatesToToolRowBeyondInitialViewport() {
val search =
ChatSearchState().apply {
isActive = true
query = "terminal"
matchIndices = listOf(30)
matchOffsets = listOf(0)
matchTargets = listOf(SearchTarget.TOOL)
currentIndex = 0
matchedIds = setOf("tool")
currentMatchId = "tool"
}
render(
messages =
List(30) { index -> msg("user-$index", MessageRole.USER) } +
ChatMessage("tool", MessageRole.TOOL, "opaque", toolName = "terminal"),
searchState = search,
)
composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed()
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
}
@Test @Test
fun userMessages_keepBubble_agentProse_isFullBleed() { fun userMessages_keepBubble_agentProse_isFullBleed() {
render( render(
@@ -1,5 +1,38 @@
package com.m57.hermescontrol.ui.chat 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 * 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 * visible content the word starts (character offset). The offset lets the
@@ -8,6 +41,7 @@ package com.m57.hermescontrol.ui.chat
data class SearchMatch( data class SearchMatch(
val messageIndex: Int, val messageIndex: Int,
val contentOffset: Int, val contentOffset: Int,
val target: SearchTarget = SearchTarget.CONTENT,
) )
/** Result of a search scan, including the exact total before any cap. */ /** Result of a search scan, including the exact total before any cap. */
@@ -24,28 +58,11 @@ data class SearchResult(
*/ */
class ChatSearchController { class ChatSearchController {
/** /**
* Find all WORD OCCURRENCES of [query] in the conversation's VISIBLE * Find literal occurrences in rendered user/assistant prose, reasoning,
* text — user bubbles and agent prose only — flattened into one entry per * and the visible tool header/summary. Never scan hidden raw tool payloads.
* occurrence, carrying each hit's character offset in its message. * Offsets are relative to each target surface, not the whole message.
* * Scans the original strings so case folding cannot shift offsets; stores
* Tool rows, system events, and reasoning text are excluded: * at most [MAX_SEARCH_MATCHES] hits while retaining the exact total.
* - 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>,
@@ -54,19 +71,43 @@ class ChatSearchController {
if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false) if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false)
val result = mutableListOf<SearchMatch>() val result = mutableListOf<SearchMatch>()
var total = 0 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 var from = 0
while (true) { while (true) {
val hit = message.content.indexOf(query, from, ignoreCase = true) val hit = text.indexOf(query, from, ignoreCase = true)
if (hit < 0) break if (hit < 0) break
total++ total++
if (result.size < MAX_SEARCH_MATCHES) { if (result.size < MAX_SEARCH_MATCHES) result.add(SearchMatch(index, hit, target))
result.add(SearchMatch(idx, hit))
}
from = hit + query.length 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) return SearchResult(result, total, capped = total > MAX_SEARCH_MATCHES)
} }
@@ -74,6 +74,7 @@ class ChatSearchDelegate(
searchState.matchIndices = result.matches.map { m -> m.messageIndex } searchState.matchIndices = result.matches.map { m -> m.messageIndex }
searchState.matchOffsets = result.matches.map { m -> m.contentOffset } searchState.matchOffsets = result.matches.map { m -> m.contentOffset }
searchState.matchTargets = result.matches.map { m -> m.target }
searchState.matchTotal = result.totalMatches searchState.matchTotal = result.totalMatches
searchState.matchCapped = result.capped searchState.matchCapped = result.capped
searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1 searchState.currentIndex = if (result.matches.isNotEmpty()) 0 else -1
@@ -106,6 +107,7 @@ class ChatSearchDelegate(
private fun resetMatches() { private fun resetMatches() {
searchState.matchIndices = emptyList() searchState.matchIndices = emptyList()
searchState.matchOffsets = emptyList() searchState.matchOffsets = emptyList()
searchState.matchTargets = emptyList()
searchState.matchTotal = 0 searchState.matchTotal = 0
searchState.matchCapped = false searchState.matchCapped = false
searchState.currentIndex = -1 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>()) var matchOffsets by mutableStateOf(emptyList<Int>())
internal set internal set
/** Parallel to [matchIndices]: rendered surface for each occurrence. */
var matchTargets by mutableStateOf(emptyList<SearchTarget>())
internal set
var currentIndex by mutableStateOf(-1) var currentIndex by mutableStateOf(-1)
internal set 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 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. * Renders chat assistant text as Markdown, including while it streams. The
* While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap * upstream token buffer coalesces deltas; parsing is memoized for identical text
* to the formatted view on completion (and for all historical/restored messages). * 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*, * Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*,
* ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, <kbd>keys</kbd>, headings, * ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, <kbd>keys</kbd>, headings,
@@ -99,25 +99,8 @@ fun MarkdownText(
) { ) {
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors) 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 linkColor = MaterialTheme.colorScheme.primary
val blocks = remember(text) { parseBlocks(text) } val blocks = remember(text, isStreaming) { parseBlocks(text) }
val latexMeasurer = rememberLatexMeasurer() val latexMeasurer = rememberLatexMeasurer()
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
@@ -125,6 +125,8 @@ internal fun composeToolSummaryLines(
internal fun ToolBubble( internal fun ToolBubble(
message: ChatMessage, message: ChatMessage,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showRawJson by remember { mutableStateOf(false) } var showRawJson by remember { mutableStateOf(false) }
@@ -169,12 +171,26 @@ internal fun ToolBubble(
.padding(horizontal = 10.dp, vertical = 6.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
) { ) {
// ── Header row: icon + tool name ── // ── 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) ── // ── Tool progress preview (tool.progress) ──
if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) { if (message.isToolRunning && !message.progressPreview.isNullOrEmpty()) {
Text( Text(
text = message.progressPreview, text =
buildHighlightedString(
message.progressPreview,
searchQuery,
isCurrentMatch,
statusColors,
),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f), color = contentColor.copy(alpha = 0.7f),
@@ -208,7 +224,7 @@ internal fun ToolBubble(
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = firstLine, text = buildHighlightedString(firstLine, searchQuery, isCurrentMatch, statusColors),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f), color = contentColor.copy(alpha = 0.7f),
@@ -221,7 +237,7 @@ internal fun ToolBubble(
} }
if (secondLine != null) { if (secondLine != null) {
Text( Text(
text = secondLine, text = buildHighlightedString(secondLine, searchQuery, isCurrentMatch, statusColors),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.5f), color = contentColor.copy(alpha = 0.5f),
@@ -32,6 +32,8 @@ internal fun ToolBubbleHeader(
contentColor: Color, contentColor: Color,
statusColors: HermesStatusColors, statusColors: HermesStatusColors,
displayName: String? = null, displayName: String? = null,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -66,7 +68,13 @@ internal fun ToolBubbleHeader(
} }
Text( 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 = style =
MaterialTheme.typography.labelMedium.copy( MaterialTheme.typography.labelMedium.copy(
color = contentColor, color = contentColor,
@@ -49,19 +49,14 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString 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.style.TextDirection
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.theme.DarkOnSurface import com.m57.hermescontrol.theme.DarkOnSurface
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.launch 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.buildHighlightedString
/** /**
* Collapsible card showing the assistant's reasoning trace * Collapsible card showing the assistant's reasoning trace
@@ -73,13 +76,25 @@ fun ReasoningCard(
reasoningText: String, reasoningText: String,
isStreaming: Boolean = false, isStreaming: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
searchOffset: Int = 0,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
LaunchedEffect(isCurrentMatch, searchQuery) {
if (isCurrentMatch && searchQuery.isNotBlank()) expanded = true
}
var fullHeight by remember { mutableStateOf(false) } var fullHeight by remember { mutableStateOf(false) }
// Copy feedback: briefly show "Copied" then revert // Copy feedback: briefly show "Copied" then revert
var copied by rememberCopyFeedback() var copied by rememberCopyFeedback()
val scrollState = rememberScrollState() 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 stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp } val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp }
@@ -150,8 +165,9 @@ fun ReasoningCard(
}, },
) )
if (isStreaming) { if (isStreaming) {
val statusColors = LocalHermesStatusColors.current
Text( Text(
text = reasoningText, text = buildHighlightedString(reasoningText, searchQuery, isCurrentMatch, statusColors),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = contentModifier, modifier = contentModifier,
@@ -160,6 +176,8 @@ fun ReasoningCard(
MarkdownText( MarkdownText(
text = reasoningText, text = reasoningText,
textColor = MaterialTheme.colorScheme.onSurfaceVariant, textColor = MaterialTheme.colorScheme.onSurfaceVariant,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
modifier = contentModifier, 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.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ImageViewerModel 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.ToolCallDivider
import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.UserBubble
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch 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 ChatSearchScrollEffect(
// (not in ChatLifecycleEffects) because only this composable knows searchState = searchState,
// the message-id → lazy-item-index mapping. Reads search fields in messages = messages,
// the effect (not the body), so only this effect restarts on change. renderedFirstId = renderedMessages.firstOrNull()?.id,
LaunchedEffect( turns = turns,
searchState.isActive, scrollController = scrollController,
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)
}
}
val currentDensity = LocalDensity.current val currentDensity = LocalDensity.current
val chatFontScale = LocalChatFontScale.current val chatFontScale = LocalChatFontScale.current
@@ -352,6 +334,22 @@ fun FullBleedChatList(
ReasoningCard( ReasoningCard(
reasoningText = reasoning.reasoningText, reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming, 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( ReasoningCard(
reasoningText = proseMessage.reasoningText, reasoningText = proseMessage.reasoningText,
isStreaming = proseMessage.isStreaming, 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 = isCurrentMatch =
searchState.currentMatchId != null && searchState.currentMatchId == proseMessage.id &&
searchState.currentMatchId == proseMessage.id, searchState.matchTargets.getOrNull(
searchState.currentIndex,
) ==
SearchTarget.CONTENT,
showReasoning = !hoistedReasoning, showReasoning = !hoistedReasoning,
onOpenAttachment = actions.onOpenAttachment, onOpenAttachment = actions.onOpenAttachment,
onSaveAttachment = actions.onSaveAttachment, onSaveAttachment = actions.onSaveAttachment,
@@ -436,7 +453,21 @@ fun FullBleedChatList(
contentType = FullBleedContentType.TOOL, contentType = FullBleedContentType.TOOL,
) { ) {
Column(modifier = Modifier.padding(bottom = 6.dp)) { 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 -> milestone?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
} }
@@ -24,6 +24,8 @@ import com.m57.hermescontrol.ui.chat.ToolBubble
internal fun FullBleedToolRow( internal fun FullBleedToolRow(
message: ChatMessage, message: ChatMessage,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) { ) {
Box( Box(
modifier = modifier =
@@ -32,7 +34,7 @@ internal fun FullBleedToolRow(
.padding(start = 16.dp, end = 16.dp) .padding(start = 16.dp, end = 16.dp)
.testTag("fullbleed_tool_row"), .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.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole 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). * 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 = private const val MAX_ITERATIONS_SYSTEM_MARKER =
"You've reached the maximum number of tool-calling iterations allowed." "You've reached the maximum number of tool-calling iterations allowed."
private fun ChatMessage.isSyntheticSystemRow(): Boolean = internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
role == MessageRole.USER && role == MessageRole.USER &&
displayKind == null && displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
@@ -186,6 +188,21 @@ fun messageIdToLazyIndex(
} }
}.toMap() }.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. */ /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> = internal fun fullBleedItemKeys(turns: List<ChatTurn>): List<String> =
buildList { buildList {
@@ -34,15 +34,25 @@ class ChatSearchControllerTest {
} }
@Test @Test
fun `excludes tool rows even when the payload contains the query`() { fun `finds visible tool name without indexing hidden raw payload`() {
val messages = val messages =
listOf( listOf(
message(MessageRole.USER, "check the logs"), message(MessageRole.USER, "check the logs"),
message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"), message(MessageRole.TOOL, "unrendered private payload").copy(toolName = "terminal"),
message(MessageRole.ASSISTANT, "all good"),
) )
assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "check the logs"))) assertEquals(listOf(SearchMatch(1, 0, SearchTarget.TOOL)), hits(controller.findMatches(messages, "terminal")))
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "private payload")))
}
@Test
fun `system marker carried as user does not yield an invisible hit`() {
val marker =
message(
MessageRole.USER,
"You've reached the maximum number of tool-calling iterations allowed.",
)
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(listOf(marker), "maximum")))
} }
@Test @Test
@@ -57,7 +67,7 @@ class ChatSearchControllerTest {
} }
@Test @Test
fun `excludes reasoning text that never made it to visible content`() { fun `finds reasoning text independently of assistant prose`() {
val messages = val messages =
listOf( listOf(
message( message(
@@ -67,8 +77,10 @@ class ChatSearchControllerTest {
), ),
) )
// Only the visible content is searchable — reasoning is invisible. assertEquals(
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "deployment"))) listOf(SearchMatch(0, 19, SearchTarget.REASONING)),
hits(controller.findMatches(messages, "deployment")),
)
assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer"))) assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer")))
} }
@@ -139,6 +139,39 @@ class ChatSearchDelegateTest {
assertEquals(0, delegate.searchState.currentIndex) assertEquals(0, delegate.searchState.currentIndex)
} }
@Test
fun `search tracks target kind while navigating reasoning tool and prose`() =
runTest {
val uiState =
MutableStateFlow(
ChatUiState(
messages =
listOf(
ChatMessage("m0", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
ChatMessage("m1", MessageRole.TOOL, "opaque", toolName = "thinking_tool"),
),
),
)
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.setSearchQuery("thinking")
advanceTimeBy(150)
runCurrent()
assertEquals(listOf(SearchTarget.REASONING, SearchTarget.TOOL), delegate.searchState.matchTargets)
assertEquals("m0", delegate.searchState.currentMatchId)
delegate.navigateSearchMatch(1)
assertEquals("m1", delegate.searchState.currentMatchId)
assertEquals(SearchTarget.TOOL, delegate.searchState.matchTargets[delegate.searchState.currentIndex])
delegate.clearSearch()
assertEquals(emptyList<SearchTarget>(), delegate.searchState.matchTargets)
}
@Test @Test
fun `clearSearch resets everything including matched sets`() = fun `clearSearch resets everything including matched sets`() =
runTest { runTest {
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
import com.m57.hermescontrol.ui.chat.fullbleed.searchMatchToLazyIndex
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -24,6 +25,21 @@ class FullBleedItemKeysTest {
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2)) assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
} }
@Test
fun searchTargetsResolveHoistedReasoningAndToolRows() {
val messages =
listOf(
ChatMessage("u", MessageRole.USER, "question"),
ChatMessage("t", MessageRole.TOOL, "output", toolName = "terminal"),
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
)
val turns = groupIntoTurns(messages)
assertEquals(1, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0, SearchTarget.REASONING)))
assertEquals(2, searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.TOOL)))
assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0)))
}
@Test @Test
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output") val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")