From ff951eaf148e12d29ef62ffcad4e38dbc025ab02 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:10:00 +0300 Subject: [PATCH] fix(chat): stop crash when expanding reasoning card with Show full (#884) * fix(chat): stop crash when expanding reasoning card with Show full ReasoningCard applied heightIn(max = Dp.Unspecified) once "Show full" was tapped. Inside a LazyColumn item the main axis is unbounded, so the verticalScroll was measured with an infinite max height and Compose threw IllegalStateException on the next layout pass. When Show full is active the scroll wrapper is dropped entirely and the trace renders at its natural height (legal inside a LazyColumn item). Regression test reproduces the crash path: ReasoningCard in a LazyColumn item, expand, Show full, collapse. * test(chat): scroll to the reasoning toggle before asserting it The card grows taller than the viewport after Show full, so the Show less button sits below the fold. performScrollTo brings it on-screen before the assert and the follow-up click. --------- Co-authored-by: M57 --- .../ui/chat/components/ReasoningCardTest.kt | 74 +++++++++++++++++++ .../ui/chat/components/MessageCards.kt | 30 +++++--- 2 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt new file mode 100644 index 00000000..494509c1 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ReasoningCardTest.kt @@ -0,0 +1,74 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Regression tests for the ReasoningCard "Show full" crash (issue #882). + * + * Inside a LazyColumn item the main axis is unbounded, so a verticalScroll + * measured with an infinite max height throws + * IllegalStateException ("Vertically scrollable component was measured with an + * infinity maximum height constraints"). The old implementation passed + * `heightIn(max = Dp.Unspecified)` once "Show full" was tapped, which lifted + * the cap entirely and reproduced exactly that crash. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class ReasoningCardTest { + @get:Rule + val composeTestRule = createComposeRule() + + /** Long enough to overflow the ~40%-of-screen collapsed cap (~22 lines). */ + private val longReasoning = + buildString { + repeat(60) { index -> appendLine("reasoning line $index") } + } + + private fun renderInLazyColumn(reasoningText: String) { + composeTestRule.setContent { + LazyColumn { + item(key = "reasoning") { + ReasoningCard(reasoningText = reasoningText) + } + } + } + } + + @Test + fun showFull_insideLazyColumn_doesNotCrash() { + renderInLazyColumn(longReasoning) + + // Expand the card. + composeTestRule.onNodeWithTag("reasoning_card").performClick() + composeTestRule.onNodeWithText("Show full").assertIsDisplayed() + + // "Show full" lifts the cap — must NOT crash the layout pass. + composeTestRule.onNodeWithText("Show full").performClick() + // The card now grows taller than the viewport, so the toggle button + // sits below the fold — scroll it into view before asserting/clicking. + composeTestRule.onNodeWithText("Show less").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText("Show less").performClick() + + // And back down again. + composeTestRule.onNodeWithText("Show full").performScrollTo().assertIsDisplayed() + } + + @Test + fun shortReasoning_noOverflow_noShowFullButton() { + renderInLazyColumn("short reasoning") + + composeTestRule.onNodeWithTag("reasoning_card").performClick() + composeTestRule.onNodeWithText("Show full").assertDoesNotExist() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 31b28909..2d6dfd40 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -73,7 +73,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R @@ -176,19 +175,26 @@ fun ReasoningCard( } AnimatedVisibility(visible = expanded) { Column { - Column( + Text( + text = reasoningText, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .heightIn(max = if (fullHeight) Dp.Unspecified else capHeight) - .verticalScroll(scrollState), - ) { - Text( - text = reasoningText, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } + .padding(top = 4.dp) + .then( + if (!fullHeight) { + // Capped at ~40% of the screen with internal scroll. + Modifier.heightIn(max = capHeight).verticalScroll(scrollState) + } else { + // "Show full": render the whole trace at natural height. + // A scrollable must NEVER be measured with an infinite + // max height — inside a LazyColumn item (unbounded main + // axis) that combination throws IllegalStateException. + Modifier + }, + ), + ) if (isStreaming) { ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp)) }