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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user