diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt new file mode 100644 index 00000000..bb7b1d1f --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatSearchRenderingTest.kt @@ -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() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt index 9758bc49..cdad49b8 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -61,4 +61,21 @@ class MarkdownHeadingRenderingTest { composeTestRule.onNodeWithText("val quoted = 1", 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) + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index cf323c1e..e036eed9 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag 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.ChatUiState 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.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import org.junit.Assert.assertFalse import org.junit.Rule import org.junit.Test @@ -78,17 +79,13 @@ class FullBleedChatListTest { showUserMessageTokens: Boolean = true, showAssistantMessageTokens: Boolean = true, showTokensPerSecond: Boolean = true, + searchState: ChatSearchState = ChatSearchState(), onController: (ChatScrollController) -> Unit = {}, ) { composeTestRule.setContent { val listState = remember { LazyListState() } - val scrollController = - remember(listState) { - ChatScrollController( - listState = listState, - scope = CoroutineScope(Dispatchers.Main.immediate), - ) - } + // Frame-clocked scope, matching ChatScreen: animated scrolls need a MonotonicFrameClock. + val scrollController = rememberChatScrollController(listState, rememberCoroutineScope()) SideEffect { onController(scrollController) } FullBleedChatList( transcript = @@ -119,7 +116,7 @@ class FullBleedChatListTest { speakingMessageId = null, ), actions = testTranscriptActions(), - searchState = ChatSearchState(), + searchState = searchState, listState = listState, scrollController = scrollController, ) @@ -139,6 +136,30 @@ class FullBleedChatListTest { 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 fun userMessages_keepBubble_agentProse_isFullBleed() { render( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt index 0548af40..8ed80979 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchController.kt @@ -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, @@ -54,19 +71,43 @@ class ChatSearchController { if (query.isBlank()) return SearchResult(emptyList(), 0, capped = false) val result = mutableListOf() 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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt index 913de983..97453ff5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegate.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt new file mode 100644 index 00000000..09720764 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchHighlight.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt index fd6d4114..c5eb0116 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSearchState.kt @@ -34,6 +34,10 @@ class ChatSearchState { var matchOffsets by mutableStateOf(emptyList()) internal set + /** Parallel to [matchIndices]: rendered surface for each occurrence. */ + var matchTargets by mutableStateOf(emptyList()) + internal set + var currentIndex by mutableStateOf(-1) internal set diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 03bf817d..5195c84e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -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~, keys, 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()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 67901cdf..e4a578b8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -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), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt index e5262dbc..63da8c1b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubbleHeader.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt index d4a73087..c2de9bba 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/UserBubble.kt @@ -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 - } - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt index 6f1517d9..14879136 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ReasoningCard.kt @@ -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, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt new file mode 100644 index 00000000..2b460802 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/ChatSearchScrollEffect.kt @@ -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, + renderedFirstId: String?, + turns: List, + 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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index c49c0eae..eca2f7a0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt index 534daafc..3762dc57 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt @@ -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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index fafc2ac3..cf5a8057 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -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, + messages: List, + 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): List = buildList { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt index b35bea74..100dad04 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchControllerTest.kt @@ -34,15 +34,25 @@ class ChatSearchControllerTest { } @Test - fun `excludes tool rows even when the payload contains the query`() { + fun `finds visible tool name without indexing hidden raw payload`() { val messages = listOf( message(MessageRole.USER, "check the logs"), - message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"), - message(MessageRole.ASSISTANT, "all good"), + message(MessageRole.TOOL, "unrendered private payload").copy(toolName = "terminal"), ) - 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(), 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(), hits(controller.findMatches(listOf(marker), "maximum"))) } @Test @@ -57,7 +67,7 @@ class ChatSearchControllerTest { } @Test - fun `excludes reasoning text that never made it to visible content`() { + fun `finds reasoning text independently of assistant prose`() { val messages = listOf( message( @@ -67,8 +77,10 @@ class ChatSearchControllerTest { ), ) - // Only the visible content is searchable — reasoning is invisible. - assertEquals(emptyList(), hits(controller.findMatches(messages, "deployment"))) + assertEquals( + listOf(SearchMatch(0, 19, SearchTarget.REASONING)), + hits(controller.findMatches(messages, "deployment")), + ) assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer"))) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt index a72289c0..5eaf44f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSearchDelegateTest.kt @@ -139,6 +139,39 @@ class ChatSearchDelegateTest { 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(), delegate.searchState.matchTargets) + } + @Test fun `clearSearch resets everything including matched sets`() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt index 0df28900..31834295 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt @@ -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.groupIntoTurns import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex +import com.m57.hermescontrol.ui.chat.fullbleed.searchMatchToLazyIndex import org.junit.Assert.assertEquals import org.junit.Test @@ -24,6 +25,21 @@ class FullBleedItemKeysTest { 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 fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")