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

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 19:31:43 +03:00
1 parent 580f3b9697
commit 85903afd38
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()
}
}
@@ -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)
}
}
@@ -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(