fix(chat): searchable tool/reasoning rows and live markdown (#1361)
This commit is contained in:
19 files changed
+454
-146
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
+31
-10
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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
|
||||
@@ -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<SearchMatch>(), 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")))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SearchTarget>(), delegate.searchState.matchTargets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearSearch resets everything including matched sets`() =
|
||||
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.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")
|
||||
|
||||
Reference in new issue
Block a user