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 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+31
-10
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user